feat(scenario): implement the phase 6 scenario director

Add Format Specification section 21 (Scenario Model 0.1) and the runtime
that realizes it: the scenario director, seven trigger classes, timelines
with repeats and branches, admission and concurrency control, nested
ownership with bounded cleanup, and the production GC5 resource counters.

Exhibit E provides a reproducible forty-minute long-scenario reference and
scenario-challenge.xzbt covers the PRD 131 cases. A standalone
acceptance/soak page is built by tools/build-scenario-acceptance.mjs.

252 automated checks pass. The two-hour real-duration development soak
required by the GC6 schedule remains pending.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
This commit is contained in:
2026-09-06 22:54:31 +00:00
co-authored by Claude Opus 5
parent c4332363a9
commit 1cde2f9f68
29 changed files with 15553 additions and 54 deletions
+668 -13
View File
@@ -151,7 +151,7 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh
<span class="eyebrow">Active exhibit</span><h2 id="active-name"></h2>
<canvas id="stage-canvas" class="stage-canvas" aria-label="Exhibit visual output"></canvas>
<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. The audio engine realizes declared graphs, and the visual engine draws declared scenes, layers, primitives, and transforms; procedural systems, automation, and post-effects attach in later slices.</p>
<p>Declarative sound, visuals, cadence, and scenarios share one logical clock. Parameter edits remain stored while a temporary scenario override is active.</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="audio-title">
<h3 id="audio-title">Audio</h3>
@@ -166,6 +166,8 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh
<div id="sound-list" class="sound-list"></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>
<section class="grammar-panel" aria-labelledby="scenarios-title"><h3 id="scenarios-title">Scenarios</h3><div id="scenario-list"></div></section>
<button id="pause-button" type="button">Pause</button>
<button id="deactivate-button" type="button">Deactivate</button>
</div>
</section>
@@ -180,7 +182,7 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh
/* src/runtime/constants.js */
const XZBT_FORMAT_VERSION = '0.1';
const XZBT_RUNTIME_VERSION = '0.1.0-phase4-stage0';
const XZBT_RUNTIME_VERSION = '0.1.0-phase6';
const UINT32_RANGE = 0x1_0000_0000;
const RNG_DOMAINS = Object.freeze([
'cadence',
@@ -5572,6 +5574,205 @@ function validateEventActions(document, actions, path, owningEventId, errors, he
});
}
/* src/runtime/scenario-validation.js */
const SCENARIO_LIMITS = Object.freeze({ definitions: 64, active: 16, entries: 1024, repeats: 1024, cleanupMs: 5000 });
// Both import paths use this validator; no clocks or resources are created here.
function validateScenarioSubsystem(document, errors, helpers = {}) {
const fail = (code, path, message) => errors.push({ code, path, message });
const shape = (value, fields, path) => {
if (!isRecord(value)) { fail('ERR_SCHEMA_VALIDATION', path, 'Expected an object.'); return false; }
for (const key of Object.keys(value)) if (!fields.includes(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Unknown field '${key}'.`);
return true;
};
const time = (value, path, positive = false, random = false) => {
if (random && isRecord(value)) {
if (!shape(value, ['random'], path) || !shape(value.random, ['min', 'max'], `${path}.random`)) return;
const min = time(value.random.min, `${path}.random.min`, positive), max = time(value.random.max, `${path}.random.max`, positive);
if (min > max) fail('ERR_INVALID_RANGE_ORDER', path, 'Time range is reversed.');
return;
}
try {
const ms = parseDuration(value, path);
if (!Number.isFinite(ms) || (positive && ms <= 0)) throw new Error('Duration must be finite and positive.');
return ms;
} catch (error) { fail('ERR_INVALID_DURATION', path, error.message); }
};
const condition = (value, path) => helpers.validateCondition?.(document, value, path, errors);
const value = (spec, path, inputs = {}) => helpers.validateValueSpec?.(document, spec, path, errors, { inputs, componentParameters: new Map(Object.entries(inputs)) });
const fields = {
set: ['target', 'value', 'transition'], override: ['target', 'value', 'scope', 'duration', 'priority', 'transition'],
event: ['event', 'with'], sound: ['sound', 'with', 'ownership'], spawn: ['target', 'with', 'ownership', 'lifetime'],
remove: ['target'], control: ['target', 'command']
};
const soundMode = sound => (sound?.recipe?.use ? document.audio?.recipes?.[sound.recipe.use]?.mode : sound?.recipe?.mode) ?? 'oneshot';
const actions = (list, path, hook = false, inputs = {}, scenario = true) => {
if (!Array.isArray(list)) { fail('ERR_SCHEMA_VALIDATION', path, 'Actions must be an array.'); return; }
list.forEach((action, index) => {
const p = `${path}[${index}]`;
if (!isRecord(action) || !fields[action.type]) { fail('ERR_SCHEMA_VALIDATION', p, 'Unsupported action type.'); return; }
shape(action, ['type', 'id', 'when', 'chance', 'critical', ...fields[action.type]], p);
if (action.id !== undefined && !ID_PATTERN.test(action.id)) fail('ERR_INVALID_ID', `${p}.id`, 'Invalid action ID.');
if (action.critical !== undefined && typeof action.critical !== 'boolean') fail('ERR_TYPE_MISMATCH', p, 'critical must be boolean.');
if (action.chance !== undefined && (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1)) fail('ERR_OUT_OF_BOUNDS', p, 'chance must be in [0,1].');
if (action.when !== undefined) condition(action.when, `${p}.when`);
if (hook && (!['set', 'sound'].includes(action.type) || (action.type === 'sound' && (soundMode(document.sounds?.[action.sound]) !== 'oneshot' || ['persistent', 'performance'].includes(action.ownership))))) fail('ERR_UNSUPPORTED_TARGET', p, 'Termination hooks permit only set and nonpersistent one-shot sound actions.');
if (['set', 'override'].includes(action.type)) {
const target = action.target;
const visual = matchVisualTarget(document, target);
const spec = typeof target === 'string' ? target.startsWith('state.') ? document.state?.[target.slice(6)] : target.startsWith('parameters.') ? document.parameters?.[target.slice(11)] : /^audio\.buses\.[^.]+\.gain$/.test(target) ? document.audio?.buses?.[target.split('.')[2]] && { type: 'number' } : visual && !visual.reason ? visual.spec : null : null;
if (!spec || (action.type === 'set' && !target.startsWith('state.'))) fail('ERR_UNSUPPORTED_TARGET', `${p}.target`, 'Target is not exposed for this action.');
value(action.value, `${p}.value`, inputs);
if (spec && !isRecord(action.value) && !valueMatchesType(spec.type, action.value, spec)) fail('ERR_TYPE_MISMATCH', `${p}.value`, 'Action value has the wrong type.');
if (action.type === 'override') {
if (!['scenario', 'duration'].includes(action.scope)) fail('ERR_SCHEMA_VALIDATION', p, 'Override scope is required.');
if (action.scope === 'duration') time(action.duration, `${p}.duration`, true);
else if (action.duration !== undefined) fail('ERR_SCHEMA_VALIDATION', p, 'Scenario override cannot declare duration.');
if (action.priority !== undefined && (!Number.isInteger(action.priority) || Math.abs(action.priority) > 1000)) fail('ERR_OUT_OF_BOUNDS', p, 'Override priority must be -1000 through 1000.');
}
if (action.transition !== undefined && shape(action.transition, action.type === 'set' ? ['duration', 'easing'] : ['in', 'out', 'easing'], `${p}.transition`)) {
for (const key of ['duration', 'in', 'out']) if (action.transition[key] !== undefined) time(action.transition[key], `${p}.transition.${key}`);
if (action.transition.easing !== undefined && !EASINGS.includes(action.transition.easing)) fail('ERR_INVALID_TRANSITION', p, 'Unknown easing.');
}
}
if (action.type === 'event' || action.type === 'sound') {
const def = action.type === 'event' ? document.events?.[action.event] : document.sounds?.[action.sound];
if (!def) fail('ERR_INVALID_REFERENCE', p, 'Unknown event or sound.');
if (scenario && action.type === 'sound' && def && !(def.usage ?? ['automatic']).includes('scenario')) fail('ERR_UNSUPPORTED_TARGET', p, 'Sound does not permit scenario usage.');
}
if (action.type === 'spawn') {
const def = document.visuals?.systems?.[action.target?.replace(/^visuals\.systems\./, '')];
if (!def || def.lifecycle !== 'spawned') fail('ERR_INVALID_REFERENCE', p, 'Spawn requires a spawned visual template.');
if (action.ownership === 'persistent' && def?.spawn?.ownership !== 'persistent') fail('ERR_UNSUPPORTED_TARGET', p, 'Template does not permit persistent ownership.');
if (action.lifetime !== undefined) value(action.lifetime, `${p}.lifetime`, inputs);
}
if (action.type === 'remove' && (typeof action.target !== 'string' || !action.target.startsWith('instances.'))) fail('ERR_UNSUPPORTED_TARGET', p, 'remove requires an instance ID.');
if (action.ownership !== undefined && !['scenario', 'persistent', ...(action.type === 'sound' ? ['performance'] : [])].includes(action.ownership)) fail('ERR_UNSUPPORTED_TARGET', p, 'Unsupported resource ownership.');
if (action.with !== undefined) {
if (isRecord(action.with)) for (const [key, spec] of Object.entries(action.with)) value(spec, `${p}.with.${key}`, inputs);
else fail('ERR_SCHEMA_VALIDATION', `${p}.with`, 'with must be an object.');
}
if (action.type === 'control') {
if (!/^scenarios\.[a-z][a-z0-9_-]*$/.test(action.target) || !document.scenarios?.[action.target?.slice(10)]) fail('ERR_INVALID_REFERENCE', p, 'Unknown scenario control target.');
if (!['start', 'stop', 'enable', 'disable'].includes(action.command)) fail('ERR_UNSUPPORTED_TARGET', p, 'Scenario controls support start, stop, enable, disable.');
}
});
};
// Strict action checking also closes the event-to-scenario dispatch boundary.
for (const [id, event] of Object.entries(document.events ?? {})) if (isRecord(event) && Array.isArray(event.actions)) actions(event.actions, `$.events.${id}.actions`, false, event.inputs, false);
if (document.scenarios === undefined) return;
if (!isRecord(document.scenarios)) { fail('ERR_SCHEMA_VALIDATION', '$.scenarios', 'scenarios must be an object.'); return; }
const definitions = Object.entries(document.scenarios);
if (definitions.length > SCENARIO_LIMITS.definitions) fail('ERR_OUT_OF_BOUNDS', '$.scenarios', 'At most 64 scenario definitions.');
for (const [id, def] of definitions) {
const p = `$.scenarios.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', p, 'Invalid scenario ID.');
if (!shape(def, ['name', 'enabled', 'priority', 'group', 'trigger', 'eligibility', 'concurrency', 'cooldown', 'duration', 'onStart', 'timeline', 'onComplete', 'onCancel', 'tags'], p)) continue;
if (def.name !== undefined && typeof def.name !== 'string') fail('ERR_TYPE_MISMATCH', `${p}.name`, 'name must be a string.');
if (def.enabled !== undefined && typeof def.enabled !== 'boolean') fail('ERR_TYPE_MISMATCH', `${p}.enabled`, 'enabled must be boolean.');
if (def.priority !== undefined && (!Number.isInteger(def.priority) || def.priority < 0 || def.priority > 100)) fail('ERR_OUT_OF_BOUNDS', `${p}.priority`, 'Scenario priority must be 0 through 100.');
if (def.group !== undefined && (typeof def.group !== 'string' || !ID_PATTERN.test(def.group))) fail('ERR_INVALID_ID', `${p}.group`, 'Invalid group ID.');
if (def.tags !== undefined && (!Array.isArray(def.tags) || def.tags.length > 16 || def.tags.some(t => typeof t !== 'string' || t.length > 32))) fail('ERR_TYPE_MISMATCH', `${p}.tags`, 'tags must contain at most 16 short strings.');
if (def.duration !== undefined) time(def.duration, `${p}.duration`, true);
if (def.cooldown !== undefined) time(def.cooldown, `${p}.cooldown`);
if (def.eligibility !== undefined && shape(def.eligibility, ['when', 'timeout'], `${p}.eligibility`)) {
if (def.eligibility.when !== undefined) condition(def.eligibility.when, `${p}.eligibility.when`);
if (def.eligibility.timeout !== undefined) time(def.eligibility.timeout, `${p}.eligibility.timeout`, true);
}
if (def.concurrency !== undefined && shape(def.concurrency, ['mode', 'scope', 'policy'], `${p}.concurrency`)) {
if (!['parallel', 'exclusive'].includes(def.concurrency.mode)) fail('ERR_SCHEMA_VALIDATION', `${p}.concurrency`, 'Expected parallel or exclusive mode.');
if (def.concurrency.scope !== undefined && !['group', 'global'].includes(def.concurrency.scope)) fail('ERR_SCHEMA_VALIDATION', p, 'Expected group or global scope.');
if (def.concurrency.policy !== undefined && !['defer', 'reject', 'replace'].includes(def.concurrency.policy)) fail('ERR_SCHEMA_VALIDATION', p, 'Expected defer, reject or replace policy.');
if (def.concurrency.scope === 'group' && !def.group) fail('ERR_SCHEMA_VALIDATION', p, 'Group scope requires group.');
}
const trigger = def.trigger ?? { type: 'manual' };
const triggerFields = { manual: [], once: ['at'], interval: ['every'], 'random-interval': ['min', 'max'], probability: ['every', 'chance'], condition: ['when', 'for'], event: ['event'] };
if (!isRecord(trigger) || !triggerFields[trigger.type]) fail('ERR_SCHEMA_VALIDATION', `${p}.trigger`, 'Unknown trigger type.');
else {
shape(trigger, ['type', ...triggerFields[trigger.type]], `${p}.trigger`);
if (trigger.type === 'once') time(trigger.at ?? '0ms', `${p}.trigger.at`);
if (['interval', 'probability'].includes(trigger.type)) time(trigger.every, `${p}.trigger.every`, true);
if (trigger.type === 'random-interval') time({ random: { min: trigger.min, max: trigger.max } }, `${p}.trigger`, true, true);
if (trigger.type === 'probability' && (!Number.isFinite(trigger.chance) || trigger.chance < 0 || trigger.chance > 1)) fail('ERR_OUT_OF_BOUNDS', p, 'Trigger chance must be in [0,1].');
if (trigger.type === 'condition') { condition(trigger.when, `${p}.trigger.when`); time(trigger.for ?? '0ms', `${p}.trigger.for`); }
if (trigger.type === 'event' && !document.events?.[trigger.event]) fail('ERR_INVALID_REFERENCE', p, 'Unknown trigger event.');
}
for (const key of ['onStart', 'onComplete', 'onCancel']) if (def[key] !== undefined) actions(def[key], `${p}.${key}`, key !== 'onStart');
if (!Array.isArray(def.timeline)) { fail('ERR_SCHEMA_VALIDATION', `${p}.timeline`, 'timeline is required.'); continue; }
if (def.timeline.length > SCENARIO_LIMITS.entries) fail('ERR_OUT_OF_BOUNDS', `${p}.timeline`, 'At most 1024 timeline entries.');
const ids = new Map();
def.timeline.forEach((entry, index) => {
const ep = `${p}.timeline[${index}]`;
if (!shape(entry, ['id', 'at', 'after', 'delay', 'repeat', 'actions', 'choose'], ep)) return;
if (entry.id !== undefined) {
if (!ID_PATTERN.test(entry.id) || ids.has(entry.id)) fail('ERR_INVALID_ID', ep, 'Timeline IDs must be valid and unique.');
ids.set(entry.id, index);
}
if ((entry.at !== undefined) === (entry.after !== undefined)) fail('ERR_SCHEMA_VALIDATION', ep, 'Use exactly one of at and after.');
if (entry.at !== undefined) { time(entry.at, `${ep}.at`); if (entry.delay !== undefined) fail('ERR_UNKNOWN_FIELD', `${ep}.delay`, 'delay requires after.'); }
else time(entry.delay ?? '0ms', `${ep}.delay`, false, true);
if (entry.repeat !== undefined && shape(entry.repeat, ['count', 'every'], `${ep}.repeat`)) {
if (!Number.isInteger(entry.repeat.count) || entry.repeat.count < 1 || entry.repeat.count > SCENARIO_LIMITS.repeats) fail('ERR_OUT_OF_BOUNDS', ep, 'Repeat count must be 1 through 1024, including the first beat.');
time(entry.repeat.every, `${ep}.repeat.every`, true, true);
}
if ((entry.actions !== undefined) === (entry.choose !== undefined)) fail('ERR_SCHEMA_VALIDATION', ep, 'Use exactly one of actions and choose.');
if (entry.actions !== undefined) actions(entry.actions, `${ep}.actions`);
if (entry.choose !== undefined) {
if (!Array.isArray(entry.choose) || !entry.choose.length) fail('ERR_SCHEMA_VALIDATION', ep, 'choose requires branches.');
else entry.choose.forEach((branch, bi) => {
const bp = `${ep}.choose[${bi}]`;
if (!shape(branch, ['weight', 'when', 'actions'], bp)) return;
if (!Number.isFinite(branch.weight) || branch.weight < 0) fail('ERR_OUT_OF_BOUNDS', bp, 'Branch weight must be finite and nonnegative.');
if (branch.when !== undefined) condition(branch.when, `${bp}.when`);
actions(branch.actions, `${bp}.actions`);
});
}
});
const visited = new Set(), visiting = new Set();
const visit = index => {
if (visiting.has(index)) { fail('ERR_CYCLIC_DEPENDENCY', `${p}.timeline`, 'Relative timing cycle.'); return; }
if (visited.has(index)) return;
visiting.add(index);
const entry = def.timeline[index];
if (entry?.after !== undefined) {
if (!ids.has(entry.after)) fail('ERR_INVALID_REFERENCE', `${p}.timeline[${index}].after`, 'Unknown timeline anchor.');
else visit(ids.get(entry.after));
}
visiting.delete(index); visited.add(index);
};
def.timeline.forEach((_, i) => visit(i));
}
// Include indirect event invocation and event-triggered starts in the same graph.
const graph = new Map();
const scan = (node, list) => {
for (const action of Array.isArray(list) ? list : []) {
if (action?.type === 'event') graph.get(node).add(`event:${action.event}`);
if (action?.type === 'control' && action.command === 'start') graph.get(node).add(`scenario:${action.target?.slice(10)}`);
}
};
for (const id of Object.keys(document.events ?? {})) graph.set(`event:${id}`, new Set());
for (const [id] of definitions) graph.set(`scenario:${id}`, new Set());
for (const [id, event] of Object.entries(document.events ?? {})) scan(`event:${id}`, event?.actions);
for (const [id, def] of definitions) {
if (def?.trigger?.type === 'event') graph.get(`event:${def.trigger.event}`)?.add(`scenario:${id}`);
const node = `scenario:${id}`;
scan(node, def?.onStart);
for (const entry of Array.isArray(def?.timeline) ? def.timeline : []) {
scan(node, entry?.actions);
for (const branch of Array.isArray(entry?.choose) ? entry.choose : []) scan(node, branch?.actions);
}
}
const done = new Set(), stack = new Set();
const visit = node => {
if (stack.has(node)) { fail('ERR_CYCLIC_DEPENDENCY', '$.scenarios', `Event/scenario feedback at ${node}.`); return; }
if (done.has(node)) return;
stack.add(node);
for (const child of graph.get(node) ?? []) visit(child);
stack.delete(node); done.add(node);
};
for (const node of graph.keys()) visit(node);
}
/* src/runtime/validator.js */
function issue(code, path, message, context = {}) {
return { code, path, message, ...context };
@@ -5649,6 +5850,7 @@ function validateExhibit(document, filename = 'document.xzbt') {
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
validateVisualSubsystem(document, errors, { validateValueSpec, pushError });
validateCadenceSubsystem(document, errors, { validateValueSpec, validateCondition, pushError });
validateScenarioSubsystem(document, errors, { validateValueSpec, validateCondition, pushError });
return { valid: errors.length === 0, errors, warnings: [], filename };
}
@@ -6125,7 +6327,10 @@ class OverrideStack {
}
releaseOwner(owner) {
for (const instance of [...this.instances.values()]) if (instance.owner === owner && instance.scope === 'scenario') this.beginRelease(instance.id);
for (const instance of [...this.instances.values()]) if (instance.owner === owner) {
instance.releaseMs = Math.min(instance.releaseMs, 5000);
this.beginRelease(instance.id);
}
}
advance() {
@@ -8366,9 +8571,10 @@ class ActionExecutor {
execute(actions, context = {}) {
if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.');
const domain = context.domain ?? 'scenario';
const stream = this.engine.rng.stream(domain, `actions:${++this.invocationOrdinal}`);
const stream = context.stream ?? this.engine.rng.stream(domain, `actions:${++this.invocationOrdinal}`);
const results = [];
for (let index = 0; index < actions.length; index += 1) {
if (!context.isTerminationHook && context.owner?.startsWith('instances.scenario-') && this.scenarios && !this.scenarios.active.has(context.owner)) break;
const action = actions[index];
this.consumeUnit(context);
try {
@@ -8377,7 +8583,7 @@ class ActionExecutor {
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;
if (action?.critical !== false || fault.code === 'ERR_DISPATCH_BUDGET') throw fault;
}
}
return results;
@@ -8385,12 +8591,16 @@ class ActionExecutor {
executeOne(action, stream, context, path) {
if (!isRecord(action) || typeof action.type !== 'string') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Action requires a type.', path);
if (context.isTerminationHook && (!['set', 'sound'].includes(action.type) || ['persistent', 'performance'].includes(action.ownership))) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Unsupported termination-hook action.', path);
if (action.when !== undefined && !this.evaluateCondition(action.when, stream, `${path}.when`, context.inputScope)) 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 === 'control' && this.scenarios) {
return this.scenarios.control(action.target?.replace(/^scenarios\./, ''), action.command, context);
}
if (action.type === 'set') {
const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope);
this.engine.setState(action.target, value, action.transition);
@@ -8448,6 +8658,7 @@ class ActionExecutor {
}
this.diagnostics?.info?.('INFO_EVENT_INVOKED', `Event '${action.event}' invoked.`, { exhibitId: this.engine.document.meta?.id, section: 'events', objectId: action.event });
this.scenarios?.notifyEvent(action.event, { ...context, inputScope });
const results = this.execute(eventDef.actions, {
...context,
depth,
@@ -8469,7 +8680,13 @@ class ActionExecutor {
}
const inputs = action.with ? Object.fromEntries(Object.entries(action.with).map(([k, v]) => [k, this.evaluateValue(v, stream, `${path}.with.${k}`, context.inputScope)])) : undefined;
const audio = this.audio ?? this.engine.audio;
const voice = audio ? audio.play(soundId, { inputs, owner: action.ownership ?? context.owner }) : null;
const recipe = targetSound.recipe?.use ? this.engine.document.audio?.recipes?.[targetSound.recipe.use] : targetSound.recipe;
if (context.isTerminationHook && (recipe?.mode ?? 'oneshot') !== 'oneshot') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Termination sounds must be one-shot.', path);
const owner = ['persistent', 'performance'].includes(action.ownership) ? 'performance' : context.owner ?? 'performance';
if ((!audio || !audio.unlocked) && recipe?.mode === 'continuous' && this.scenarios && !context.isTerminationHook) {
return this.scenarios.deferContinuous(soundId, { inputs, owner });
}
const voice = audio ? audio.play(soundId, { inputs, owner }) : null;
return { status: voice ? 'executed' : 'refused', type: 'sound', sound: soundId, voiceId: voice?.id ?? null };
}
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Action '${action.type}' belongs to a later subsystem phase.`, `${path}.type`);
@@ -8802,6 +9019,355 @@ class CadenceSubsystem {
}
}
/* src/runtime/scenario.js */
const SCENARIO_EPSILON = 1e-6;
class ScenarioDirector {
constructor({ document, resolution, actions, rng, diagnostics = null, onTrace = null }) {
Object.assign(this, { document, resolution, actions, rng, diagnostics, onTrace });
this.active = new Map();
this.pending = new Map();
this.cleanups = new Map();
this.continuousRequests = [];
this.definitions = new Map();
this.disposed = false;
this.now = resolution.logicalMilliseconds;
let order = 0;
for (const [id, definition] of Object.entries(document.scenarios ?? {})) {
const trigger = definition.trigger ?? { type: 'manual' };
const stream = rng.stream('scenario', `${id}:trigger:1`);
const state = { id, definition, trigger, order: order++, priority: definition.priority ?? 50,
enabled: definition.enabled !== false, ordinal: 0, lastEnd: -Infinity, stream,
conditionState: 'disarmed', holdAt: null, next: Infinity, status: 'DEFINED', lastState: null };
if (trigger.type === 'once') state.next = parseDuration(trigger.at ?? '0ms');
if (['interval', 'probability'].includes(trigger.type)) state.next = parseDuration(trigger.every);
if (trigger.type === 'random-interval') state.next = this.sampleTime({ random: trigger }, stream);
this.definitions.set(id, state);
}
actions.scenarios = this;
}
sampleTime(spec, stream) {
if (typeof spec === 'string' || typeof spec === 'number') return parseDuration(spec);
const min = parseDuration(spec.random.min), max = parseDuration(spec.random.max);
return min + stream.nextFloat() * (max - min);
}
trace(type, instance, details = {}) {
this.onTrace?.({ type, time: this.now, scenario: instance.definitionId ?? instance.id, instance: instance.owner ?? null, ...details });
}
audioVoices() {
const audio = this.actions.audio;
return [...new Set([...(audio?.voices ?? []), ...(audio?.oneshotVoices ?? []), ...(audio?.continuousVoices ?? [])])];
}
deferContinuous(soundId, options) {
if (this.continuousRequests.length >= 16) return { status: 'refused', type: 'sound' };
this.continuousRequests.push({ soundId, options });
return { status: 'scheduled', type: 'sound' };
}
resumeContinuous() {
if (!this.actions.audio?.unlocked) return;
for (const request of this.continuousRequests.splice(0)) this.actions.audio.play(request.soundId, request.options);
}
eligible(state) {
return state.enabled && this.now + SCENARIO_EPSILON >= state.lastEnd + parseDuration(state.definition.cooldown ?? '0ms')
&& (!state.definition.eligibility?.when || this.resolution.evaluateCondition(state.definition.eligibility.when, state.stream));
}
conflicts(state) {
return [...this.active.values()].filter(instance => {
if (instance.definitionId === state.id) return true;
const other = this.definitions.get(instance.definitionId);
const a = state.definition.concurrency ?? {}, b = other.definition.concurrency ?? {};
const blocks = (config, left, right) => config.mode === 'exclusive'
&& ((config.scope ?? 'global') === 'global' || left.definition.group === right.definition.group);
return blocks(a, state, other) || blocks(b, other, state);
});
}
request(id, context = {}) {
if (this.disposed) return { status: 'refused' };
const state = this.definitions.get(id);
if (!state) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown scenario '${id}'.`);
if (this.pending.has(id)) return { status: 'deferred' };
this.pending.set(id, { id, created: this.now, expires: this.now + parseDuration(state.definition.eligibility?.timeout ?? '5m'),
sourceOwner: context.owner ?? 'performance', triggerInputs: context.inputScope ? { ...context.inputScope } : null, attempted: false });
if (![...this.active.values()].some(i => i.definitionId === id)) state.status = 'SCHEDULED';
return { status: 'scheduled' };
}
notifyEvent(id, context = {}) {
if (this.disposed || context.isTerminationHook) return;
for (const state of this.definitions.values()) if (state.trigger.type === 'event' && state.trigger.event === id) this.request(state.id, context);
}
control(id, command, context = {}) {
const state = this.definitions.get(id);
if (!state) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown scenario '${id}'.`);
if (command === 'start') return this.request(id, context);
if (command === 'stop') { this.cancel(id); return { status: 'executed' }; }
if (command === 'enable' || command === 'disable') {
state.enabled = command === 'enable';
return { status: 'executed' };
}
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Unsupported scenario command '${command}'.`);
}
cancel(id) {
this.pending.delete(id);
for (const instance of [...this.active.values()]) if (instance.definitionId === id || instance.owner === id) this.terminate(instance, 'CANCELLED');
}
sampleTimeline(state, stream) {
const entries = state.definition.timeline;
const byId = new Map(entries.map((entry, i) => [entry.id, i]));
const times = new Map();
const at = index => {
if (times.has(index)) return times.get(index);
const entry = entries[index];
const due = entry.at !== undefined ? parseDuration(entry.at) : at(byId.get(entry.after)) + this.sampleTime(entry.delay ?? '0ms', stream);
times.set(index, due); return due;
};
return entries.map((entry, index) => ({ entry, index, due: at(index), beat: 0 }));
}
start(state) {
const ordinal = ++state.ordinal;
const owner = `instances.scenario-${state.id}-${ordinal}`;
const instance = { owner, definitionId: state.id, priority: state.priority, order: state.order, started: this.now,
stream: this.rng.stream('scenario', `${state.id}:${ordinal}`), state: 'STARTING', records: [],
end: state.definition.duration === undefined ? Infinity : parseDuration(state.definition.duration) };
this.active.set(owner, instance);
this.resolution.signals.set('signals.scenario.active', true);
this.resolution.invalidate();
state.status = 'STARTING';
this.trace('start', instance);
try {
this.actions.execute(state.definition.onStart ?? [], this.context(instance));
if (!this.active.has(owner)) return;
instance.records = this.sampleTimeline(state, instance.stream);
instance.state = state.status = 'ACTIVE';
} catch (error) { this.fail(instance, error); }
}
context(instance) { return { owner: instance.owner, origin: instance.owner, priority: instance.priority, domain: 'scenario', usage: 'scenario', stream: instance.stream }; }
fail(instance, error) {
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', objectId: instance.definitionId, owner: instance.owner, tick: this.resolution.tickIndex });
this.terminate(instance, 'FAILED');
}
evaluateTriggers() {
for (const state of this.definitions.values()) {
const t = state.trigger;
if (t.type === 'condition') {
const truth = this.resolution.evaluateCondition(t.when, state.stream);
if (!truth) { state.conditionState = 'armed'; state.holdAt = null; }
else if (state.conditionState !== 'disarmed') {
if (state.holdAt === null) state.holdAt = this.now;
state.conditionState = 'holding';
if (this.now - state.holdAt + SCENARIO_EPSILON >= parseDuration(t.for ?? '0ms')) {
state.conditionState = 'disarmed'; this.request(state.id);
}
}
} else if (state.next <= this.now + SCENARIO_EPSILON) {
if (t.type !== 'probability' || state.stream.nextFloat() < t.chance) this.request(state.id);
if (t.type === 'once') state.next = Infinity;
else state.next += t.type === 'random-interval' ? this.sampleTime({ random: t }, state.stream) : parseDuration(t.every);
// No catch-up burst for sub-tick authoring intervals.
if (state.next <= this.now) state.next = this.now + 1000 / 60;
}
}
}
dispatchPending() {
let started = false;
const queue = [...this.pending.values()].sort((a, b) => this.definitions.get(b.id).priority - this.definitions.get(a.id).priority || a.created - b.created || this.definitions.get(a.id).order - this.definitions.get(b.id).order);
for (const request of queue) {
if (!this.pending.has(request.id)) continue;
const state = this.definitions.get(request.id);
const policy = state.definition.concurrency?.policy ?? 'defer';
const conflicts = this.conflicts(state);
const allowed = this.eligible(state);
const canReplace = conflicts.length && policy === 'replace' && conflicts.every(i => state.priority > i.priority);
if (!allowed || (conflicts.length && !canReplace) || this.active.size - (canReplace ? conflicts.length : 0) >= SCENARIO_LIMITS.active) {
request.attempted = true;
if (policy !== 'defer') { this.pending.delete(request.id); state.status = 'DEFINED'; }
continue;
}
this.actions.consumeUnit({ owner: request.sourceOwner });
this.pending.delete(request.id);
if (canReplace) for (const instance of conflicts) this.terminate(instance, 'CANCELLED');
this.start(state); started = true;
}
return started;
}
nextRecord() {
const due = [];
for (const instance of this.active.values()) for (const record of instance.records) {
if (record.due <= instance.end + SCENARIO_EPSILON && instance.started + record.due <= this.now + SCENARIO_EPSILON) due.push({ instance, record });
}
due.sort((a, b) => (a.instance.started + a.record.due) - (b.instance.started + b.record.due) || a.instance.order - b.instance.order || a.record.index - b.record.index);
return due[0];
}
dispatchRecord(instance, record) {
this.actions.consumeUnit(this.context(instance)); // even an empty/skipped beat is bounded
const entry = record.entry;
let list = entry.actions ?? [];
if (entry.choose) {
const branches = entry.choose.filter(branch => branch.weight > 0 && (!branch.when || this.resolution.evaluateCondition(branch.when, instance.stream)));
// Normalize first, so finite large weights cannot overflow their sum.
const max = Math.max(0, ...branches.map(branch => branch.weight));
const sum = branches.reduce((n, branch) => n + branch.weight / max, 0);
let roll = instance.stream.nextFloat() * sum;
const branch = branches.find(branch => (roll -= branch.weight / max) < 0);
list = branch?.actions ?? [];
this.trace('branch', instance, { entry: record.index, branch: branch ? entry.choose.indexOf(branch) : null });
}
this.trace('beat', instance, { entry: record.index, beat: record.beat, due: record.due });
this.actions.execute(list, this.context(instance));
if (!this.active.has(instance.owner)) return;
record.beat += 1;
if (record.beat < (entry.repeat?.count ?? 1)) record.due += this.sampleTime(entry.repeat.every, instance.stream);
else instance.records.splice(instance.records.indexOf(record), 1);
}
update() {
if (this.disposed) return;
this.now = this.resolution.logicalMilliseconds;
this.resumeContinuous();
this.advanceCleanup();
for (const [id, request] of this.pending) if (request.expires <= this.now + SCENARIO_EPSILON) { this.pending.delete(id); this.definitions.get(id).status = 'DEFINED'; }
try {
this.evaluateTriggers();
// Work generated by an event/control joins this tick's bounded drain.
for (;;) {
const started = this.dispatchPending();
const next = this.nextRecord();
if (next) {
try { this.dispatchRecord(next.instance, next.record); }
catch (error) { this.fail(next.instance, error); if (error.code === 'ERR_DISPATCH_BUDGET') throw error; }
continue;
}
let completed = false;
for (const instance of [...this.active.values()]) if (this.now - instance.started + SCENARIO_EPSILON >= instance.end || (instance.end === Infinity && instance.records.length === 0)) {
this.terminate(instance, 'COMPLETED'); completed = true;
}
if (!started && !completed) break;
}
} catch (error) {
// Only owners implicated in this tick's discarded work fail.
const discarded = [...this.pending.values()].filter(r => !r.attempted);
const owners = new Set(discarded.map(r => r.sourceOwner));
for (const i of this.active.values()) if (i.records.some(r => i.started + r.due <= this.now + SCENARIO_EPSILON)) owners.add(i.owner);
for (const request of discarded) this.pending.delete(request.id);
for (const owner of owners) if (this.active.has(owner)) this.fail(this.active.get(owner), error);
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', tick: this.resolution.tickIndex });
}
this.resolution.signals.set('signals.scenario.active', this.active.size > 0);
this.resolution.invalidate();
}
terminate(instance, cause) {
if (!this.active.has(instance.owner)) return;
this.active.delete(instance.owner); // blocks reentrant dispatch before the hook
this.resolution.signals.set('signals.scenario.active', this.active.size > 0);
this.resolution.invalidate();
instance.records.length = 0;
const state = this.definitions.get(instance.definitionId);
instance.state = cause === 'COMPLETED' ? 'COMPLETING' : 'CANCELLING';
const cleanupOwner = `cleanup:${instance.owner}`;
const hook = cause === 'COMPLETED' ? state.definition.onComplete : state.definition.onCancel;
const context = { ...this.context(instance), owner: cleanupOwner, isTerminationHook: true, terminationUnits: 0 };
try {
for (const action of hook ?? []) {
try { this.actions.execute([action], context); }
catch (error) {
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', objectId: state.id });
if (context.terminationUnits > 256) break;
}
}
} finally {
const until = this.now + SCENARIO_LIMITS.cleanupMs;
const overrides = this.resolution.overrides;
for (const override of [...overrides.instances.values()]) if (override.owner === instance.owner) {
override.owner = cleanupOwner; override.releaseMs = Math.min(override.releaseMs, SCENARIO_LIMITS.cleanupMs);
overrides.beginRelease(override.id);
}
this.continuousRequests = this.continuousRequests.filter(r => r.options.owner !== instance.owner);
for (const voice of this.audioVoices()) if (voice.owner === instance.owner || voice.owner === cleanupOwner) {
voice.owner = cleanupOwner;
voice.releaseMs = Math.min(voice.releaseMs, SCENARIO_LIMITS.cleanupMs);
if (voice.mode === 'continuous') voice.stop();
// Logical cleanup also works with native audio paused or accelerated tests.
voice.cleanupAt = Math.min(until, this.now + (voice.mode === 'continuous' ? voice.releaseMs : (voice.endingBoundMs ?? SCENARIO_LIMITS.cleanupMs) + voice.releaseMs));
}
this.actions.visual?.cleanup(instance.owner);
this.cleanups.set(cleanupOwner, { owner: cleanupOwner, origin: instance.owner, until });
instance.state = state.lastState = cause;
state.status = cause; state.lastEnd = this.now;
this.trace('terminate', instance, { cause });
this.advanceCleanup();
}
}
advanceCleanup(force = false) {
for (const [id, cleanup] of this.cleanups) {
const expired = force || this.now + SCENARIO_EPSILON >= cleanup.until;
let remaining = 0, forced = false;
for (const voice of this.audioVoices()) if (voice.owner === id) {
if (expired || this.now + SCENARIO_EPSILON >= voice.cleanupAt) { voice.dispose(); forced ||= expired && voice.cleanupAt > this.now; }
else remaining++;
}
for (const override of [...this.resolution.overrides.instances.values()]) if (override.owner === id) {
if (expired) { this.resolution.overrides.instances.delete(override.id); forced = true; }
else remaining++;
}
const visual = this.actions.visual;
for (const v of [...(visual?.instances.values() ?? [])]) if (v.owner === cleanup.origin || (v.origin === cleanup.origin && v.cancelWithScenario)) {
if (expired) {
if (v.state !== 'FINISHED') v.transition('FINISHED');
v.transition('DISPOSED'); visual.instances.delete(v.id);
visual.systems = visual.systems.filter(s => s !== v.system); forced = true;
} else remaining++;
}
if (forced && !force) this.diagnostics?.warn?.('WARN_CLEANUP_FORCED', 'Scenario cleanup reached its five-second deadline.', { section: 'scenarios', owner: id });
if (!remaining || expired) this.cleanups.delete(id);
}
this.resolution.invalidate();
}
counters() {
const voices = this.audioVoices();
const visual = this.actions.visual;
return { scenarios: this.active.size, deferred: this.pending.size, cleanupOwners: this.cleanups.size,
schedulerRecords: [...this.active.values()].reduce((n, i) => n + i.records.length, 0) + this.pending.size + this.continuousRequests.length,
subscriptions: this.disposed ? 0 : [...this.definitions.values()].filter(s => s.trigger.type === 'event').length,
overrides: this.resolution.overrides.instances.size, audioVoices: voices.length,
audioNodes: voices.reduce((n, v) => n + (v.plan?.nodes?.length ?? 0), 0),
visualInstances: visual?.instances.size ?? 0, visualSystems: visual?.systems.length ?? 0 };
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.now = this.resolution.logicalMilliseconds;
this.pending.clear();
this.continuousRequests.length = 0;
for (const instance of [...this.active.values()]) this.terminate(instance, 'CANCELLED');
this.advanceCleanup(true);
this.resolution.signals.set('signals.scenario.active', false);
this.resolution.invalidate();
this.actions.scenarios = null;
}
}
/* src/runtime/performance.js */
class CommonGrammarPerformance {
constructor(record, rootSeed, options = {}) {
@@ -8822,6 +9388,9 @@ class CommonGrammarPerformance {
onParameterChange: options.onParameterChange
});
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
this.actions.audio = options.audio ?? null;
if (this.actions.audio) this.actions.audio.useLogicalClock = true;
this.scenarios = new ScenarioDirector({ document: record.document, resolution: this.engine, actions: this.actions, rng: this.rng, diagnostics: this.diagnostics, onTrace: options.onScenarioTrace });
this.cadence = new CadenceSubsystem({
document: record.document,
rng: this.rng,
@@ -8834,6 +9403,7 @@ class CommonGrammarPerformance {
this.frameRequest = null;
this.lastFrame = undefined;
this.accumulator = 0;
this.pauseReasons = new Set();
this.boundFrame = (now) => this.frame(now);
this.boundPointer = (event) => {
this.engine.signals.set('signals.pointer.x', event.clientX);
@@ -8864,14 +9434,16 @@ class CommonGrammarPerformance {
const observed = Math.max(0, now - this.lastFrame);
this.lastFrame = now;
const accepted = Math.min(observed, 250);
if (this.pauseReasons.size) {
this.onFrame(this.engine);
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
return;
}
this.accumulator += accepted;
const step = 1000 / 60;
let ticks = 0;
while (this.accumulator + 1e-9 >= step && ticks < 8) {
this.actions.resetBudget();
this.engine.advance(step);
this.cadence.advance(step);
this.onTick(step, this.engine);
this.tick();
this.accumulator -= step;
ticks += 1;
}
@@ -8886,9 +9458,30 @@ class CommonGrammarPerformance {
setAudio(audio) {
this.actions.audio = audio;
if (audio) audio.useLogicalClock = true;
this.cadence.setAudio(audio);
}
tick() {
if (this.pauseReasons.size || this.scenarios.disposed) return;
const step = 1000 / 60;
this.actions.resetBudget();
this.engine.advance(step);
this.actions.audio?.advanceLogical?.();
this.cadence.advance(step);
this.scenarios.update();
this.onTick(step, this.engine);
}
// Development acceleration executes the same fixed ticks without rendering.
advanceTicks(count) {
if (!Number.isSafeInteger(count) || count < 0) throw new RangeError('Tick count must be a nonnegative safe integer.');
for (let i = 0; i < count; i++) this.tick();
}
pause(reason = 'user') { this.pauseReasons.add(reason); this.lastFrame = undefined; this.accumulator = 0; }
resume(reason = 'user') { this.pauseReasons.delete(reason); this.lastFrame = undefined; this.accumulator = 0; }
setParameter(id, value) {
const result = this.engine.setParameter(id, value);
this.onUpdate(this.engine);
@@ -8909,6 +9502,7 @@ class CommonGrammarPerformance {
}
async deactivate() {
this.scenarios.dispose();
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
this.lastFrame = undefined;
@@ -8922,6 +9516,7 @@ class CommonGrammarPerformance {
}
async dispose() {
this.scenarios.dispose();
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
if (typeof globalThis.removeEventListener === 'function') {
@@ -9626,6 +10221,7 @@ class SoundInstance {
if (this.subsystem) {
this.subsystem.voices.delete(this);
}
if (this.subsystem?.useLogicalClock) this.logicalReleaseAt = this.subsystem.resolution.logicalMilliseconds;
if (this.state === 'CREATED') {
this.transition('FINISHED');
return;
@@ -9634,7 +10230,7 @@ class SoundInstance {
this.transition('RELEASING');
if (this.releaseMs === 0) {
this.transition('FINISHED');
} else {
} else if (!this.subsystem?.useLogicalClock) {
this.releaseTimer = setTimeout(() => {
if (this.state === 'RELEASING') {
this.transition('FINISHED');
@@ -9666,6 +10262,7 @@ class SoundInstance {
gainParam.setValueAtTime?.(gainParam.value, now);
gainParam.linearRampToValueAtTime?.(0, now + durationSec);
}
if (this.subsystem?.useLogicalClock) return;
this.releaseTimer = setTimeout(() => {
if (this.state === 'RELEASING') {
this.transition('FINISHED');
@@ -10016,6 +10613,7 @@ class AudioSubsystem {
instance.realize(this.context, this.busFor(soundId));
instance.transition('ACTIVE');
instance.startTime = instance.realized.startTime;
instance.logicalStartedAt = this.resolution.logicalMilliseconds;
this.voices.add(instance);
} catch (error) {
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
@@ -10024,7 +10622,7 @@ class AudioSubsystem {
return null;
}
if (mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
if (!this.useLogicalClock && mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
instance.endingTimer = setTimeout(() => {
if (instance.state === 'ACTIVE') {
this.voices.delete(instance);
@@ -10041,6 +10639,18 @@ class AudioSubsystem {
for (const instance of [...this.voices]) instance.stop();
}
advanceLogical() {
if (!this.useLogicalClock) return;
const now = this.resolution.logicalMilliseconds;
for (const voice of [...this.oneshotVoices, ...this.continuousVoices]) {
if (voice.state === 'ACTIVE' && voice.mode === 'oneshot' && Number.isFinite(voice.endingBoundMs) && now - voice.logicalStartedAt >= voice.endingBoundMs) {
this.voices.delete(voice); voice.transition('FINISHED');
}
if (voice.state === 'RELEASING' && now - voice.logicalReleaseAt >= voice.releaseMs) voice.transition('FINISHED');
if (voice.state === 'FINISHED') voice.dispose();
}
}
async dispose() {
this.disposed = true;
this.ready = false;
@@ -10132,6 +10742,19 @@ class XZBTApplication {
}
bindEvents() {
element('pause-button').addEventListener('click', async () => {
const p = this.activation.current?.performance;
if (!p) return;
if (p.pauseReasons.has('user')) p.resume(); else p.pause();
element('pause-button').textContent = p.pauseReasons.has('user') ? 'Resume' : 'Pause';
await this.syncAudioPause();
});
document.addEventListener('visibilitychange', async () => {
const p = this.activation.current?.performance;
if (!p) return;
if (document.hidden) p.pause('visibility'); else p.resume('visibility');
await this.syncAudioPause();
});
element('import-files').addEventListener('change', async (event) => {
await this.importFiles([...event.target.files]);
event.target.value = '';
@@ -10211,9 +10834,9 @@ class XZBTApplication {
if (this.busy || !this.activation.current) return;
this.setBusy(true, 'Deactivating exhibit…');
try {
await this.activation.deactivate();
await this.disposeAudio();
this.visual.deactivate();
await this.activation.deactivate();
this.renderLibrary();
this.renderStage();
} finally {
@@ -10290,6 +10913,32 @@ class XZBTApplication {
this.renderConfiguration(current.performance);
this.renderValues(current.performance.engine);
this.renderAudio();
this.renderScenarios();
}
async syncAudioPause() {
const context = this.audio?.context;
if (!context) return;
try {
if (this.activation.current?.performance.pauseReasons.size) await context.suspend();
else if (context.state === 'suspended') await context.resume();
} catch (error) { this.diagnostics.warn('WARN_AUDIO_UNAVAILABLE', error.message, { section: 'audio' }); }
}
renderScenarios() {
const container = element('scenario-list');
container.replaceChildren();
const director = this.activation.current?.performance.scenarios;
for (const [id, state] of director?.definitions ?? []) {
const row = document.createElement('div');
row.append(text('span', state.definition.name ?? id));
const start = text('button', 'Start'); start.type = 'button'; start.onclick = () => director.request(id);
const stop = text('button', 'Cancel'); stop.type = 'button'; stop.onclick = () => director.cancel(id);
const status = text('output', state.status); status.dataset.scenarioStatus = id;
row.append(start, stop, status); container.append(row);
}
if (!director?.definitions.size) container.append(text('p', 'No scenarios in this exhibit.'));
element('pause-button').textContent = 'Pause';
}
async unlockAudio() {
@@ -10306,6 +10955,8 @@ class XZBTApplication {
}
await this.audio.unlock();
current.performance.setAudio(this.audio);
current.performance.scenarios.resumeContinuous();
await this.syncAudioPause();
this.renderAudio();
}
@@ -10465,6 +11116,10 @@ class XZBTApplication {
}
renderValues(engine) {
for (const output of document.querySelectorAll('[data-scenario-status]')) {
const state = this.activation.current?.performance.scenarios.definitions.get(output.dataset.scenarioStatus);
if (state) output.textContent = state.status;
}
const container = element('resolved-values');
if (!container) return;
const snapshot = engine.snapshot();
+193 -9
View File
@@ -1,8 +1,8 @@
# XZBT Format Specification 0.1
**XZBT format version:** 0.1
**Document revision:** 0.9
**Status:** Normative specification for shared contracts (document, types, ValueSpec, ConditionSpec, resolution, bindings, and transitions); the Audio subsystem contract is complete (Phase 3a sources/control sources, Phase 3b processing/routing/components/buses, Phase 3c automation/lifecycle/protection); master-protection values remain provisional pending GC6 measurement; the Visual subsystem contract is complete (Phase 4a scene/primitives/transforms/appearance in section 17, Phase 4b components/procedural systems/behaviors/fields in section 18, and Phase 4c automation/lifecycle/camera/post-effects/ceilings in section 19); the aggregate visual ceilings of 19.5 remain provisional pending the slice 4h GC6 measurement; the Cadence and Event Subsystems contract is complete in section 20 (Phase 5); and the remaining subsystem contracts are in progress
**Document revision:** 0.10
**Status:** Normative specification for shared contracts (document, types, ValueSpec, ConditionSpec, resolution, bindings, and transitions); the Audio subsystem contract is complete (Phase 3a sources/control sources, Phase 3b processing/routing/components/buses, Phase 3c automation/lifecycle/protection); master-protection values remain provisional pending GC6 measurement; the Visual subsystem contract is complete (Phase 4a scene/primitives/transforms/appearance in section 17, Phase 4b components/procedural systems/behaviors/fields in section 18, and Phase 4c automation/lifecycle/camera/post-effects/ceilings in section 19); the aggregate visual ceilings of 19.5 remain provisional pending the slice 4h GC6 measurement; the Cadence and Event Subsystems contract is complete in section 20 (Phase 5); Scenario Model 0.1 is complete in section 21 (Phase 6); UI and library-hardening contracts remain in progress
**Related resources:** [PRD](../XZBT_0-1_MVP_Product_Requirements_Document.md), [decisions](XZBT_0-1_Gap_Closure_Decisions.md), [verification](XZBT_0-1_Verification_Gates.md)
This document defines normative syntax and runtime semantics for XZBT 0.1 exhibits. An exhibit is a UTF-8 JSON document that configures generic procedural visual, audio, cadence, and orchestration primitives. It does not contain executable JavaScript.
@@ -535,7 +535,7 @@ Complete shared contracts before implementing dependent subsystems. Use PRD sect
| Audio | 34-60 | Complete recipe/component shapes, node defaults, automation timing, clamp implementation, one-shot endings and tails, unlock behavior, master protection contract | **Complete (Rev 0.4 / Phase 3a-3c):** units, graph objects, all sixteen node types, routing, modulation, graph legality, authoring limits, components, sounds/recipes, buses, automation tracks and precedence, lifecycle states and release, determinable one-shot endings, voice ceilings, unlock and pause behavior, and the master-protection contract shape. Master-protection *values* (peak ceiling, tolerance, release behavior) are provisional pending GC6 measurement |
| Cadence | 61-68 | Exact selection/cooldown/overlap fields, clocks, pool collisions, pending audio, manual sampling isolation and continuous-sample termination | **Complete (Rev 0.9 / Phase 5):** section 20 fixes cadence classes, clocks, interval ranges, selection algorithm, cooldown, overlap policy, anti-repetition relaxation, minimum automatic gap, ambient maintenance, and manual SAMPLE PRNG stream isolation |
| Visuals | 69-89 | Primitive/system/behavior fields, angle and motion units, transform order, depth projection, field behavior, morph compatibility, effect approximation and limits | **Complete (Rev 0.7 / Phase 4a-4c):** section 17 fixes the pipeline, canonical units and the angle convention, the `visuals` container, layers, the scene model and coordinate/fit modes, depth sign and sorting, the fourteen primitives, common properties, transform composition order, appearance and the safe blend set, paths and splines, and the once-at-instantiation resolution boundary. Section 18 fixes visual components and their `inputs` scope, particle systems and their normative integrator, the nine placement distributions, emitters and exact emission timing, repeaters and the `repeat.*` scope, the seventeen-behavior vocabulary and its channel set, the six procedural fields and their normative coherent-noise function, and trails, ribbons, and links. Section 19 fixes visual automation and its two declaration scopes and loop modes, the four visual rows it adds to the section 8.1 table and nothing beyond them, the persistent and spawned system lifecycle and its ownership, the camera matrix and both projection modes, the seven post-effects and the boundary of permitted approximation, and the centralized runtime ceilings for the whole engine. Aggregate ceiling *values* are provisional pending the slice 4h GC6 measurement |
| Events/scenarios | 90-102 | Shared ownership, hooks/failure ordering, condition rearming, deferred ordering/expiry, dispatch limits; full trigger/timeline shapes remain for Phase 6 | **Shared lifecycle contract complete (Rev 0.3 / GC5); Event Model 0.1 complete (Rev 0.9 / Phase 5 in section 20); scenario triggers/timelines Phase 6** |
| Events/scenarios | 90-102 | Shared ownership, hooks/failure ordering, condition rearming, deferred ordering/expiry, dispatch limits; complete trigger/timeline shapes | **Shared lifecycle contract complete (GC5); Event Model in section 20; Scenario Model in section 21 (Revision 0.10 / Phase 6)** |
| Generated UI | 103-107 | Widget compatibility, button actions, parameter validation and override display, group/control ordering | Subsystem contract (Phase 7) |
| Runtime/library | 108-112, 117-127 | Clock/audio synchronization, stalls, seed/stream derivation complete in GC4; import equality, update compatibility, transactions, failure recovery, persistence schema remain | **Shared clock/PRNG contract complete (Rev 0.3 / GC4); subsystem remainder Phase 1/8** |
@@ -3397,10 +3397,10 @@ When a class timer expires:
* $2$ selections ago: multiplier $0.25$
* $3$ selections ago: multiplier $0.50$
* $4$ selections ago: multiplier $0.75$
* Older / not in recency queue: multiplier $1.0$
* Older / not in recency queue: multiplier `1`
$\text{effectiveWeight} = \text{baseWeight} \times \text{multiplier}$.
6. **Pool Relaxation (PRD 65):** If the sum of effective weights for all remaining candidates is $0$ (which occurs when all eligible sounds are penalized to zero, such as in single-sound pools):
* Relax anti-repetition penalties: set all multipliers to $1.0$ ($\text{effectiveWeight} = \text{baseWeight}$).
* Relax anti-repetition penalties: set all multipliers to `1` ($\text{effectiveWeight} = \text{baseWeight}$).
* If the sum of base weights is still $0$, the class firing is skipped with no sound played.
7. **Weighted Selection:** Draw a pseudo-random value $u \in [0, 1)$ from `rng.stream('cadence', '<class>:select:<ordinal>')` and pick the winning sound proportional to its effective weight.
8. **Execution:** Instantiate and play the winning sound via the audio subsystem.
@@ -3411,7 +3411,7 @@ When a class timer expires:
To prevent simultaneous or jarringly close auditory collisions between different automatic classes:
* The runtime tracks $\text{lastAutomaticSoundTime}$, the logical timestamp of the most recent automatic one-shot start.
* A class firing is permitted only if $\text{currentLogicalTime} - \text{lastAutomaticSoundTime} \ge \text{minGap}$ (default $1.5\text{s}$).
* A class firing is permitted only if $\text{currentLogicalTime} - \text{lastAutomaticSoundTime} \ge \text{minGap}$ (default `1500ms`).
* If multiple classes become due simultaneously or while a gap hold is in effect, they are queued and serviced in strict canonical priority order:
$$\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}$$
* Deferred classes retain their firing opportunity: when the required $\text{minGap}$ has elapsed, the highest-priority deferred class fires immediately and resets the gap timer.
@@ -3569,10 +3569,194 @@ Phase 5 reuses existing standard codes from the Section 7 table:
6. **Sound Action Execution:** Sound actions validate sound references, pass inputs to recipes, check caller usage permissions, and reject unauthorized usage with `ERR_UNSUPPORTED_TARGET`.
7. **Cadence Eligibility & Cooldown:** A sound with `when: false` or active cooldown ($\text{elapsed} < \text{cooldown}$) is excluded from selection.
8. **Cadence Overlap Policy:** A sound with `overlap: false` is excluded from selection while any voice of that sound is active in the audio engine; a sound with `overlap: true` admits overlapping voices up to voice ceilings.
9. **Cadence Anti-Repetition Multipliers:** Consecutive firings verify multipliers $0.0$, $0.25$, $0.50$, $0.75$, $1.0$ against the class recency history.
9. **Cadence Anti-Repetition Multipliers:** Consecutive firings verify multipliers `0`, `0.25`, `0.50`, `0.75`, `1` against the class recency history.
10. **Pool Relaxation:** When all eligible sounds have effective weight zero, anti-repetition penalties relax to base weights and selection succeeds rather than permanently stalling.
11. **Cadence Intensity Scaling:** Decreasing intensity increases interval spacing ($T / \text{intensity}$); setting intensity to $0$ stops one-shot class scheduling completely.
12. **Minimum Automatic Gap & Priority:** Simultaneous class firings enforce `minGap` (default $1.5\text{s}$) between audio starts and service deferred classes in priority order ($\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}$).
12. **Minimum Automatic Gap & Priority:** Simultaneous class firings enforce `minGap` (default `1500ms`) between audio starts and service deferred classes in priority order ($\text{rare} > \text{occasional} > \text{intermittent} > \text{routine}$).
13. **Ambient Maintenance:** Ambient sounds (`cadence.class: "ambient"`) auto-start on audio unlock and are maintained by the runtime.
14. **Manual SAMPLE Isolation:** Manual SAMPLE playback draws exclusively from `rng.stream('sample', ...)` and leaves cadence clocks, cooldowns, recency history, and automatic PRNG sequences byte-identical.
14. **Manual SAMPLE Isolation:** Manual SAMPLE playback draws exclusively from the `manual-sample` domain and leaves cadence clocks, cooldowns, recency history, and automatic PRNG sequences byte-identical.
## 21. Scenario Model 0.1
This section completes PRD 92102. Sections 9 and 10 remain authoritative for clock,
ownership, dispatch and failure. Scenarios are finite declarative performances. Their
director runs on the same fixed tick as cadence, after resolution and cadence and before
visual advancement. It never installs a timer for a timeline entry. Rendering samples the
current result and does not advance scenarios.
### 21.1 Definition and limits
`scenarios` is an ID-keyed object. Unknown fields in every object below are
`ERR_UNKNOWN_FIELD`; wrong shapes are `ERR_SCHEMA_VALIDATION`. IDs use section 1.
| Field | Type | Default / requirement |
| --- | --- | --- |
| `name` | string | Optional display name; ID is the fallback |
| `enabled` | boolean | `true`; affects new starts, does not cancel an active instance |
| `priority` | integer | `50`; range 0 through 100 |
| `group` | ID string | Optional; required with group exclusivity |
| `trigger` | trigger object | `{ "type": "manual" }` |
| `eligibility` | object with optional `when`, `timeout` | ConditionSpec and positive literal duration; timeout defaults to `5m` |
| `concurrency` | object | Fields and defaults in section 21.3 |
| `cooldown` | literal duration | `0ms`; measured from the last termination, including failure |
| `duration` | positive literal duration | Optional upper bound; without it completion follows the last beat |
| `onStart` | ActionSpec array | `[]` |
| `timeline` | timeline-entry array | Required; may be empty |
| `onComplete`, `onCancel` | restricted ActionSpec arrays | `[]`; section 10.2 |
| `tags` | string array | Optional; at most 16 strings of at most 32 characters |
At most 64 definitions, 1024 entries per definition, and 1024 total occurrences of
one repeated entry are authorable; excess is `ERR_OUT_OF_BOUNDS`. The runtime admits
at most 16 active instances and at most one instance of each definition. Capacity is a
start conflict and obeys the requested conflict policy. These scenario limits extend
the centralized inventory of section 19.5 and remain provisional pending GC6.
Instance IDs are `instances.scenario-<definition>-<ordinal>`, with a monotonically
increasing ordinal per definition. The director retains only live instances, bounded
cleanup owners, and the last terminal status per definition. Completed histories are
not retained. The lifecycle follows PRD 93: definition/eligibility/scheduling,
`STARTING`, `ACTIVE`, `COMPLETING` or `CANCELLING`, then `COMPLETED`, `CANCELLED` or
`FAILED`. Exactly one termination hook runs. `signals.scenario.active` is true when
any scenario instance is starting or active.
### 21.2 Trigger shapes and sampling
| Type | Additional fields | Opportunity |
| --- | --- | --- |
| `manual` | none | Start API or scenario control action |
| `once` | optional `at` (default `0ms`) | First tick at/after elapsed performance time |
| `interval` | required positive `every` | After each interval, starting one interval after activation |
| `random-interval` | required positive `min`, `max`, min <= max | Independently sample the first and each subsequent interval |
| `probability` | required positive `every`, required numeric `chance` in [0,1] | One probability draw per interval |
| `condition` | required `when`, optional nonnegative `for` (default `0ms`) | Hold/rearm state machine in section 10.3 |
| `event` | required declared `event` ID | Notification at event invocation, before its actions |
Triggers use performance logical time. The first true tick starts a condition's hold
timer; subsequent true ticks accumulate the hold. A true initial condition remains
disarmed until a false tick. Firing consumes the edge even if admission is deferred or
rejected. Disabled definitions still advance trigger clocks and rearming state; admission
rechecks enabled. Intervals shorter than a tick produce at most one opportunity per tick,
with no catch-up burst. Invalid durations use `ERR_INVALID_DURATION`, reversed ranges
use `ERR_INVALID_RANGE_ORDER`, missing references use `ERR_INVALID_REFERENCE`.
Each definition has one stream `scenario / <id>:trigger:1`. Each admitted instance
uses `scenario / <id>:<ordinal>` for its start actions, timeline timing, weighted branches,
repeat intervals, and nested action values, in execution order. Neither manual sampling
nor another scenario's actions consume that stream. Sound and visual instantiation use
their own domains. Trigger event input values are copied into a deferred request once;
they are not an authorable scenario input namespace in 0.1.
### 21.3 Admission, exclusivity, and controls
`concurrency` permits exactly `mode` (`parallel` or `exclusive`, required when the
object is present), `scope` (`global` default, or `group`), and `policy` (`defer`
default, `reject`, or `replace`). Parallel is the default mode. Exclusivity is symmetric:
either participant's global exclusive scope blocks overlap; group exclusive scope blocks
overlap with the same group. Same-definition overlap always conflicts.
`replace` succeeds only if the incoming priority is strictly greater than every
conflicting instance's priority. Check eligibility and capacity before cancelling any
victim. Equal/lower priority rejects. `reject` drops an inadmissible opportunity;
`defer` retains it under section 10.4. Failed eligibility or cooldown obeys the same
policy. Replacement hooks and cleanup precede the incoming start hook. Pending admission
sorts by descending priority, creation logical time, then definition document order.
Starts and beats consume one ordinary dispatch unit each in addition to their actions;
even empty-start feedback is bounded by section 10.5.
`control` actions for this subsystem have `target: "scenarios.<id>"` and `command`
equal to `start`, `stop`, `enable` or `disable`. Stop cancels an active instance and
removes the definition's deferred request. Enable/disable does not reset cooldown or
trigger state. Other scenario commands are `ERR_UNSUPPORTED_TARGET`. Global pause and
resume are runtime controls and preserve independent user and visibility pause reasons.
Events generated during ordinary dispatch may admit scenarios later in the same tick;
new scenarios get their own owner. Static validation rejects event/scenario invocation
and trigger feedback cycles with `ERR_CYCLIC_DEPENDENCY`, including indirect events and
timeline branches. This conservative check rejects cycles even if authored conditions
would make them unreachable. The runtime budget remains the second backstop.
### 21.4 Timeline, relative timing, repeats, and branches
An entry permits exactly `id`, `at`, `after`, `delay`, `repeat`, `actions`, `choose`.
Use exactly one of `at` (nonnegative literal duration from instance start) or `after`
(another entry's unique ID). `delay` belongs only to `after`, defaults to `0ms`, and
accepts a nonnegative literal or `{ "random": { "min": "20s", "max": "50s" } }`.
The anchor is the first occurrence's scheduled time, independent of its action outcomes
or repeat count. Forward anchors are legal; missing anchors and cycles are rejected.
After successful `onStart`, resolve first-occurrence timestamps by walking entries in
document order, resolving each anchor before its dependent and sampling each relative
delay once. An instance keeps one mutable scheduling record per entry rather than
expanding every repetition. Beats execute in non-decreasing scheduled time; ties use
scenario definition document order, then entry document order. Timing is quantized only
at dispatch: execute on the first tick at/after the scheduled timestamp. A skipped or
zero-weight branch still counts as an occurrence and anchors dependent entries.
`repeat` permits exactly required integer `count` (11024, including the first beat)
and required positive `every` (literal or bounded random TimeSpec). Sample the next
interval after each occurrence and add it to the preceding scheduled timestamp.
Choose exactly one of `actions` or nonempty `choose`. A branch permits required finite
nonnegative numeric `weight`, optional ConditionSpec `when`, and required `actions`.
At each occurrence evaluate branch conditions, discard ineligible/zero weights, draw
one weighted choice and run its actions. An empty eligible pool is a no-op.
An explicit duration keeps the instance active even after its timeline empties. Beats
at the duration execute before completion; later beats are cancelled. Without duration,
completion follows the last beat, including repeats. Empty undurated scenarios finish
in their start tick. The application exposes the director's start/cancel/status controls;
the final schema-generated presentation belongs to Phase 7.
### 21.5 Resource ownership and termination
Section 10 governs all termination causes. Indirect event resources retain the owner,
priority, usage authorization and stream of their caller. Sound `ownership: "scenario"`
means that concrete caller owner; `persistent` and the existing `performance` spelling
transfer to the performance root. A continuous sound requested before audio unlock is
retained as a bounded logical intent (at most 16), removed if its owner terminates, and
started when audio is available. Missed one-shots are not replayed. This does not claim
sample-accurate reconstruction of pre-unlock oscillator phase.
Termination removes future timeline records before running the hook, continues past
hook failures, and transfers both scenario-scope and duration overrides and live audio
to a cleanup owner. Visual cleanup honors the independent originating-scenario relation
of section 19.2. Release durations and cleanup retention are capped at five logical
seconds. Counters include releasing audio pools, not only the actively playing set.
Performance teardown runs hooks before disposing audio/visual engines, and force-clears
cleanup immediately. No persistent `set` is rolled back. Persistent resources remain
until performance teardown or explicit removal.
### 21.6 Required examples and verification traces
Minimal five-second scenario:
```json
{"xzbt":"0.1","meta":{"id":"five-second","name":"Five seconds"},"scenarios":{"brief":{"trigger":{"type":"once"},"duration":"5s","timeline":[]}}}
```
The composition example is `exhibits/exhibit-e.xzbt`: a 40-minute sequence with random
trigger timing, random relative delays, repeated beats, weighted branching, persistent
state, cadence interaction, continuous sound, visual spawning, override and recovery.
Exhibits A and B add temporary parameter overrides. `exhibits/scenario-challenge.xzbt`
provides short, multi-minute, condition, event, parallel, exclusive and deferred cases.
Invalid examples: `priority: 101``ERR_OUT_OF_BOUNDS`; `{ "at": "1s", "after":
"a", "actions": [] }` → `ERR_SCHEMA_VALIDATION`; `after: "missing"` →
`ERR_INVALID_REFERENCE`; reciprocal `after` anchors → `ERR_CYCLIC_DEPENDENCY`;
`onCancel: [{ "type": "spawn", "target": "visuals.systems.burst" }]`
`ERR_UNSUPPORTED_TARGET`; `onFailure: []``ERR_UNKNOWN_FIELD`.
1. Five-second and multi-minute duration endpoints, including a beat exactly at the endpoint.
2. Equal-time order, forward relative references, random delays and lazy finite repeats.
3. Weighted branching, excluded branches, persistent mutations and unchanged manual PRNG stream.
4. Once, interval, probability, random interval, manual, condition and event triggers.
5. Startup-true condition disarming, hold cancellation and false-tick rearming (GC5).
6. Deferred deduplication, retained expiry, eligibility/cooldown recheck, and priority ordering (GC5).
7. Symmetric global/group exclusivity, parallel starts, higher-priority replacement and capacity.
8. Nested event ownership using production visual and audio instances and override counters (GC5).
9. Completion, cancellation, critical startup/action failure and hook failure preserve cleanup and prior set (GC5).
10. Duration overrides release at owner termination, masked user edits survive, and five-second cleanup is bounded (GC5).
11. Ordinary dispatch/depth failures preserve the independent 256-action termination budget (GC5).
12. Repeated completion/cancellation/failure cycles return resource and scheduler counters to baseline (GC5).
13. Forty-minute Exhibit E runs accelerated through the same fixed-step production tick; equal seeds give equal traces.
14. Pause/resume preserves logical time; disposal leaves no owned resources or subscriptions.
15. Two real hours on a frozen combined workload, with GC6 environment, frame intervals, resource/memory trend,
audio peak/nonfinite measurements and listening observations. Accelerated traces cannot satisfy this gate.
+162 -1
View File
@@ -4,7 +4,7 @@
"id": "exhibit-a",
"name": "Exhibit A — Procedural Machine",
"version": "0.1.0",
"description": "First visual composition; full reference exhibit acceptance remains Phase 9."
"description": "Procedural audiovisual reference with cadence and a temporary scenario override; final acceptance remains Phase 9."
},
"runtime": {
"seed": 42
@@ -106,5 +106,166 @@
}
}
}
},
"parameters": {
"activity": {
"type": "number",
"default": 0.4,
"min": 0,
"max": 1,
"label": "Activity"
}
},
"bindings": [
{
"source": "parameters.activity",
"target": "visuals.camera.zoom",
"scale": 0.25,
"offset": 1
}
],
"cadence": {
"intensity": {
"ref": "parameters.activity"
}
},
"audio": {
"buses": {
"ambient": {
"gain": 0.5
},
"effects": {
"gain": 0.5
}
}
},
"sounds": {
"bed": {
"name": "Distant hum",
"bus": "ambient",
"usage": [
"automatic"
],
"cadence": {
"class": "ambient"
},
"recipe": {
"mode": "continuous",
"release": "1s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 55
},
"trim": {
"type": "gain",
"gain": 0.04
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"drone": {
"name": "Passing resonance",
"bus": "ambient",
"usage": [
"scenario"
],
"recipe": {
"mode": "continuous",
"release": "2s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 165
},
"trim": {
"type": "gain",
"gain": 0.035
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"beat": {
"name": "Soft impulse",
"bus": "effects",
"usage": [
"automatic",
"scenario",
"manual"
],
"cadence": {
"class": "routine",
"overlap": false,
"cooldown": "3s"
},
"recipe": {
"mode": "oneshot",
"release": "50ms",
"nodes": {
"hit": {
"type": "impulse",
"duration": "8ms",
"amplitude": 0.06
}
},
"routes": [
{
"from": "hit",
"to": "output"
}
]
}
}
},
"scenarios": {
"disturbance": {
"name": "Temporary disturbance",
"trigger": {
"type": "random-interval",
"min": "30s",
"max": "90s"
},
"duration": "12s",
"cooldown": "20s",
"priority": 70,
"onStart": [
{
"type": "override",
"target": "parameters.activity",
"value": 0.95,
"scope": "scenario",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
}
},
{
"type": "sound",
"sound": "drone"
}
],
"timeline": []
}
}
}
+162 -1
View File
@@ -4,7 +4,7 @@
"id": "exhibit-b",
"name": "Exhibit B — Deep Abstract Field",
"version": "0.1.0",
"description": "First visual composition; full reference exhibit acceptance remains Phase 9."
"description": "Procedural audiovisual reference with cadence and a temporary scenario override; final acceptance remains Phase 9."
},
"runtime": {
"seed": 42
@@ -174,5 +174,166 @@
"intensity": 0.5
}
]
},
"parameters": {
"activity": {
"type": "number",
"default": 0.4,
"min": 0,
"max": 1,
"label": "Activity"
}
},
"bindings": [
{
"source": "parameters.activity",
"target": "visuals.camera.zoom",
"scale": 0.25,
"offset": 1
}
],
"cadence": {
"intensity": {
"ref": "parameters.activity"
}
},
"audio": {
"buses": {
"ambient": {
"gain": 0.5
},
"effects": {
"gain": 0.5
}
}
},
"sounds": {
"bed": {
"name": "Distant hum",
"bus": "ambient",
"usage": [
"automatic"
],
"cadence": {
"class": "ambient"
},
"recipe": {
"mode": "continuous",
"release": "1s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 55
},
"trim": {
"type": "gain",
"gain": 0.04
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"drone": {
"name": "Passing resonance",
"bus": "ambient",
"usage": [
"scenario"
],
"recipe": {
"mode": "continuous",
"release": "2s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 165
},
"trim": {
"type": "gain",
"gain": 0.035
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"beat": {
"name": "Soft impulse",
"bus": "effects",
"usage": [
"automatic",
"scenario",
"manual"
],
"cadence": {
"class": "routine",
"overlap": false,
"cooldown": "3s"
},
"recipe": {
"mode": "oneshot",
"release": "50ms",
"nodes": {
"hit": {
"type": "impulse",
"duration": "8ms",
"amplitude": 0.06
}
},
"routes": [
{
"from": "hit",
"to": "output"
}
]
}
}
},
"scenarios": {
"disturbance": {
"name": "Temporary disturbance",
"trigger": {
"type": "random-interval",
"min": "30s",
"max": "90s"
},
"duration": "12s",
"cooldown": "20s",
"priority": 70,
"onStart": [
{
"type": "override",
"target": "parameters.activity",
"value": 0.95,
"scope": "scenario",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
}
},
{
"type": "sound",
"sound": "drone"
}
],
"timeline": []
}
}
}
+439
View File
@@ -0,0 +1,439 @@
{
"xzbt": "0.1",
"meta": {
"id": "exhibit-e",
"name": "Exhibit E — Long Passage",
"version": "0.1.0",
"description": "A forty-minute procedural passage: approach, repeated signals, branching disturbance, and recovery."
},
"runtime": {
"seed": 42
},
"parameters": {
"activity": {
"type": "number",
"default": 0.35,
"min": 0,
"max": 1,
"label": "Activity"
}
},
"state": {
"mode": {
"type": "string",
"initial": "normal"
},
"beats": {
"type": "integer",
"initial": 0
},
"journeys": {
"type": "integer",
"initial": 0
}
},
"audio": {
"buses": {
"ambient": {
"gain": 0.5
},
"effects": {
"gain": 0.5
}
}
},
"sounds": {
"bed": {
"name": "Distant hum",
"bus": "ambient",
"usage": [
"automatic"
],
"cadence": {
"class": "ambient"
},
"recipe": {
"mode": "continuous",
"release": "1s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 55
},
"trim": {
"type": "gain",
"gain": 0.04
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"drone": {
"name": "Passing resonance",
"bus": "ambient",
"usage": [
"scenario"
],
"recipe": {
"mode": "continuous",
"release": "2s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 165
},
"trim": {
"type": "gain",
"gain": 0.035
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"beat": {
"name": "Soft impulse",
"bus": "effects",
"usage": [
"automatic",
"scenario",
"manual"
],
"cadence": {
"class": "routine",
"overlap": false,
"cooldown": "3s"
},
"recipe": {
"mode": "oneshot",
"release": "50ms",
"nodes": {
"hit": {
"type": "impulse",
"duration": "8ms",
"amplitude": 0.06
}
},
"routes": [
{
"from": "hit",
"to": "output"
}
]
}
}
},
"cadence": {
"intensity": {
"ref": "parameters.activity"
},
"minGap": "1500ms"
},
"bindings": [
{
"source": "parameters.activity",
"target": "visuals.camera.zoom",
"scale": 0.2,
"offset": 1
}
],
"visuals": {
"scene": {
"coordinateSpace": "virtual",
"width": 960,
"height": 540,
"background": "#101d25"
},
"systems": {
"horizon": {
"type": "graphic",
"content": {
"ring": {
"type": "ring",
"radius": 150,
"innerRadius": 148,
"position": {
"x": 480,
"y": 270
},
"style": {
"fill": "#80beb5"
}
},
"line": {
"type": "line",
"position": {
"x": 240,
"y": 270
},
"to": {
"x": 480,
"y": 0
},
"style": {
"stroke": "#dfc18d",
"strokeWidth": 2
}
}
}
},
"signal": {
"type": "graphic",
"lifecycle": "spawned",
"spawn": {
"lifetime": "4s",
"release": "1s"
},
"content": {
"ring": {
"type": "ring",
"radius": 165,
"innerRadius": 160,
"position": {
"x": 480,
"y": 270
},
"style": {
"fill": "#efbe79"
},
"behaviors": [
{
"type": "rotate",
"speed": 12
}
]
}
}
}
}
},
"events": {
"signal": {
"actions": [
{
"type": "set",
"target": "state.beats",
"value": {
"op": "add",
"args": [
{
"ref": "state.beats"
},
1
]
}
},
{
"type": "sound",
"sound": "beat"
},
{
"type": "spawn",
"target": "visuals.systems.signal"
}
]
},
"recover": {
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
]
}
},
"scenarios": {
"journey": {
"name": "Long passage",
"priority": 70,
"group": "passage",
"trigger": {
"type": "random-interval",
"min": "45m",
"max": "60m"
},
"concurrency": {
"mode": "exclusive",
"scope": "group",
"policy": "defer"
},
"duration": "40m",
"cooldown": "5m",
"onStart": [
{
"type": "set",
"target": "state.journeys",
"value": {
"op": "add",
"args": [
{
"ref": "state.journeys"
},
1
]
}
},
{
"type": "set",
"target": "state.mode",
"value": "approach"
},
{
"type": "sound",
"sound": "drone"
},
{
"type": "override",
"target": "parameters.activity",
"value": 0.7,
"scope": "scenario",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
}
}
],
"timeline": [
{
"id": "arrival",
"at": "1m",
"actions": [
{
"type": "event",
"event": "signal"
}
]
},
{
"after": "arrival",
"delay": {
"random": {
"min": "20s",
"max": "50s"
}
},
"repeat": {
"count": 12,
"every": {
"random": {
"min": "1m",
"max": "2m"
}
}
},
"actions": [
{
"type": "event",
"event": "signal"
}
]
},
{
"at": "15m",
"choose": [
{
"weight": 3,
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "drift"
},
{
"type": "override",
"target": "parameters.activity",
"value": 0.5,
"scope": "duration",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
},
"duration": "5m"
}
]
},
{
"weight": 1,
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "disturbance"
},
{
"type": "override",
"target": "parameters.activity",
"value": 0.95,
"scope": "duration",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
},
"duration": "5m"
},
{
"type": "event",
"event": "signal"
}
]
}
]
},
{
"at": "30m",
"actions": [
{
"type": "event",
"event": "recover"
}
]
},
{
"at": "40m",
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
]
}
],
"onComplete": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
],
"onCancel": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
]
}
}
}
+404
View File
@@ -0,0 +1,404 @@
{
"xzbt": "0.1",
"meta": {
"id": "scenario-challenge",
"name": "Scenario Challenge",
"version": "1.0.0",
"description": "Short, multi-minute, event, condition and concurrent scenario acceptance cases."
},
"runtime": {
"seed": 42
},
"parameters": {
"activity": {
"type": "number",
"default": 0.35,
"min": 0,
"max": 1,
"label": "Activity"
}
},
"state": {
"mode": {
"type": "string",
"initial": "normal"
},
"beats": {
"type": "integer",
"initial": 0
},
"journeys": {
"type": "integer",
"initial": 0
},
"armed": {
"type": "boolean",
"initial": false
}
},
"audio": {
"buses": {
"ambient": {
"gain": 0.5
},
"effects": {
"gain": 0.5
}
}
},
"sounds": {
"bed": {
"name": "Distant hum",
"bus": "ambient",
"usage": [
"automatic"
],
"cadence": {
"class": "ambient"
},
"recipe": {
"mode": "continuous",
"release": "1s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 55
},
"trim": {
"type": "gain",
"gain": 0.04
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"drone": {
"name": "Passing resonance",
"bus": "ambient",
"usage": [
"scenario"
],
"recipe": {
"mode": "continuous",
"release": "2s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 165
},
"trim": {
"type": "gain",
"gain": 0.035
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"beat": {
"name": "Soft impulse",
"bus": "effects",
"usage": [
"automatic",
"scenario",
"manual"
],
"cadence": {
"class": "routine",
"overlap": false,
"cooldown": "3s"
},
"recipe": {
"mode": "oneshot",
"release": "50ms",
"nodes": {
"hit": {
"type": "impulse",
"duration": "8ms",
"amplitude": 0.06
}
},
"routes": [
{
"from": "hit",
"to": "output"
}
]
}
}
},
"cadence": {
"intensity": {
"ref": "parameters.activity"
},
"minGap": "1500ms"
},
"bindings": [
{
"source": "parameters.activity",
"target": "visuals.camera.zoom",
"scale": 0.2,
"offset": 1
}
],
"visuals": {
"scene": {
"coordinateSpace": "virtual",
"width": 960,
"height": 540,
"background": "#101d25"
},
"systems": {
"horizon": {
"type": "graphic",
"content": {
"ring": {
"type": "ring",
"radius": 150,
"innerRadius": 148,
"position": {
"x": 480,
"y": 270
},
"style": {
"fill": "#80beb5"
}
},
"line": {
"type": "line",
"position": {
"x": 240,
"y": 270
},
"to": {
"x": 480,
"y": 0
},
"style": {
"stroke": "#dfc18d",
"strokeWidth": 2
}
}
}
},
"signal": {
"type": "graphic",
"lifecycle": "spawned",
"spawn": {
"lifetime": "4s",
"release": "1s"
},
"content": {
"ring": {
"type": "ring",
"radius": 165,
"innerRadius": 160,
"position": {
"x": 480,
"y": 270
},
"style": {
"fill": "#efbe79"
},
"behaviors": [
{
"type": "rotate",
"speed": 12
}
]
}
}
}
}
},
"events": {
"signal": {
"actions": [
{
"type": "set",
"target": "state.beats",
"value": {
"op": "add",
"args": [
{
"ref": "state.beats"
},
1
]
}
},
{
"type": "sound",
"sound": "beat"
},
{
"type": "spawn",
"target": "visuals.systems.signal"
}
]
},
"recover": {
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
]
},
"request": {
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "requested"
}
]
}
},
"scenarios": {
"brief": {
"name": "Five seconds",
"duration": "5s",
"timeline": [
{
"at": "0ms",
"actions": [
{
"type": "event",
"event": "signal"
}
]
}
],
"onStart": [
{
"type": "override",
"target": "parameters.activity",
"value": 0.9,
"scope": "scenario",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
}
}
]
},
"minutes": {
"name": "Three minutes",
"duration": "3m",
"timeline": [
{
"at": "10s",
"repeat": {
"count": 8,
"every": "20s"
},
"actions": [
{
"type": "event",
"event": "signal"
}
]
}
]
},
"condition": {
"name": "Condition hold",
"trigger": {
"type": "condition",
"when": {
"op": "eq",
"left": {
"ref": "state.armed"
},
"right": true
},
"for": "2s"
},
"duration": "5s",
"timeline": [],
"onStart": [
{
"type": "override",
"target": "parameters.activity",
"value": 0.6,
"scope": "scenario",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
}
}
]
},
"event": {
"name": "Event trigger",
"trigger": {
"type": "event",
"event": "request"
},
"duration": "5s",
"timeline": [],
"onStart": [
{
"type": "event",
"event": "signal"
}
]
},
"exclusive": {
"name": "Exclusive group",
"priority": 80,
"group": "g",
"concurrency": {
"mode": "exclusive",
"scope": "group"
},
"duration": "10s",
"timeline": [],
"onStart": [
{
"type": "sound",
"sound": "drone"
}
]
},
"deferred": {
"name": "Deferred group",
"priority": 50,
"group": "g",
"concurrency": {
"mode": "exclusive",
"scope": "group",
"policy": "defer"
},
"duration": "5s",
"timeline": [],
"onStart": [
{
"type": "event",
"event": "signal"
}
]
}
}
}
+3 -1
View File
@@ -17,6 +17,8 @@
"test": "node --test test/*.test.mjs",
"build:visual-acceptance": "node tools/build-visual-acceptance.mjs",
"test:phase4": "node --test test/phase4-*.test.mjs",
"test:phase5": "node test/phase5-cadence.test.mjs"
"test:phase5": "node test/phase5-cadence.test.mjs",
"test:phase6": "node test/phase6-scenarios.test.mjs",
"build:scenario-acceptance": "node tools/build-scenario-acceptance.mjs"
}
}
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
# Phase 6 scenario acceptance
Build with `npm run build:scenario-acceptance`, then open
`XZBT-scenario-acceptance.html` directly in desktop Chromium. It contains the production
runtime and all fixtures; no server or network is required.
1. Select Exhibit E and start Long passage. Start audio to hear the scenario's continuous
voice. Edit/cancel/restart through the controls and inspect status and counters.
2. Use the Scenario Challenge for five-second, three-minute, parallel/exclusive,
deferred, event, and condition cases. The condition requires an observed false tick
before a true hold. Start Exclusive group, then Deferred group to observe its queue.
3. Start Exhibit E before selecting **Advance 40 logical minutes**. Acceleration
runs production fixed ticks without audio; it is not audio or soak acceptance.
4. For the real development soak, use a reference computer at a 1920 × 1080 viewport,
keep the browser visible, provide CPU/GPU/driver/RAM/OS/power details, and select
**Start two-hour soak**. Allow 30 seconds warm-up and 7200 measured real seconds.
The button restarts the frozen `workload-v1.xzbt` (seed 4206) and unlocks audio.
It starts a 40-minute journey alongside five-second resource cycles every 20 seconds.
5. Record listening observations, including any clicks, clipping or glitches. Export
evidence after the measurement window and audio capture both finish. Pauses or
visibility changes invalidate an uninterrupted soak; rerun from the start.
The report includes workload/runtime SHA-256, environment, frame-interval histogram and
p50/p95/p99, long frames (>50ms), estimated dropped frames relative to 60 Hz, resource
counters each logical second, browser heap samples each ten seconds when available,
bounded diagnostics/trace history, and exact worklet peak/nonfinite/frame measurements.
Missing browser heap data is recorded as null; it cannot prove a memory plateau.
`audioNodes` counts retained expanded graph nodes, not every internal native DSP node.
Use the browser's heap profiler for retained-memory acceptance if the heap API is absent.
The frozen workload is a Phase 6 development-soak fixture, not a claim that the pending
Phase 4h hardware benchmark has run. GC6 ceilings, audio protection/listening acceptance,
and the two-hour soak remain open until actual measurements are recorded and reviewed.
An accelerated forty-minute trace must never be reported as a real-duration soak.
File diff suppressed because one or more lines are too long
+122
View File
@@ -0,0 +1,122 @@
// Bundled into the self-contained acceptance page, after the production runtime.
const sa = id => document.getElementById(id);
let saPerformance, saVisual, saAudio, saRun, saLastFrame, saLastSecond = -1, saAccelerating = false;
const saSelect = sa('fixture');
for (const fixture of scenarioFixtures) { const option = document.createElement('option'); option.value = fixture.meta.id; option.textContent = fixture.meta.name; saSelect.append(option); }
function saRefresh() {
const director = saPerformance.scenarios;
for (const output of document.querySelectorAll('[data-scenario]')) output.textContent = director.definitions.get(output.dataset.scenario)?.status;
sa('counters').textContent = JSON.stringify(director.counters(), null, 2);
sa('status').textContent = `${(saPerformance.engine.logicalMilliseconds / 1000).toFixed(1)} logical seconds · ${saRun.mode} · ${saRun.finished ? 'measurement window finished; export and review evidence' : 'running'}`;
}
async function saRestart() {
if (saPerformance) await saPerformance.dispose();
if (saAudio) await saAudio.dispose();
saVisual?.deactivate(); saAudio = null;
const exhibit = scenarioFixtures.find(f => f.meta.id === saSelect.value);
saRun = { ...scenarioBuild, fixture: exhibit.meta.id, seed: exhibit.runtime.seed, mode: 'interactive', startedAt: new Date().toISOString(),
realStart: performance.now(), frames: new Map(), longFrames: 0, droppedFrames: 0, samples: [], memory: [], diagnostics: [], trace: [], finished: false, visibilityInterruptions: 0, audio: null };
const diagnostics = new Diagnostics({ onChange: entries => {
saRun.diagnostics = entries; sa('diagnostics').textContent = entries.slice(-8).map(e => `${e.code}: ${e.message}`).join('\n');
} });
saVisual = new VisualSubsystem({ diagnostics }); saVisual.attach(sa('canvas'));
saPerformance = new CommonGrammarPerformance({ id: exhibit.meta.id, document: exhibit }, exhibit.runtime.seed, {
diagnostics, onScenarioTrace: e => { if (saRun.trace.length < 10000) saRun.trace.push(e); },
onTick: step => saVisual.advance(step), onFrame: () => {
saVisual.render({ logicalMilliseconds: saPerformance.engine.logicalMilliseconds });
const now = performance.now();
if (saLastFrame !== undefined && saRun.mode === 'real-time-soak' && now - saRun.realStart >= 30000 && !saRun.finished) {
const interval = now - saLastFrame, bucket = Math.min(100000, Math.round(interval * 10));
saRun.frames.set(bucket, (saRun.frames.get(bucket) ?? 0) + 1);
if (interval > 50) saRun.longFrames++;
saRun.droppedFrames += Math.max(0, Math.round(interval / (1000 / 60)) - 1);
}
saLastFrame = now;
const second = Math.floor(saPerformance.engine.logicalMilliseconds / 1000);
if (second !== saLastSecond) {
saLastSecond = second;
if (saRun.samples.length < 7500 && !saRun.finished) saRun.samples.push({ second, realMs: now - saRun.realStart, ...saPerformance.scenarios.counters() });
if (second % 10 === 0 && saRun.memory.length < 750 && !saRun.finished) saRun.memory.push({ second, usedJSHeapSize: performance.memory?.usedJSHeapSize ?? null });
saRefresh();
}
if (saRun.mode === 'real-time-soak' && now - saRun.realStart >= 7230000 && !saRun.finished) { saRun.finished = true; saRefresh(); }
}
});
saVisual.activate(exhibit, { resolution: saPerformance.engine, rng: saPerformance.rng }); saPerformance.actions.visual = saVisual.engine;
await saPerformance.activate();
if (document.hidden) saPerformance.pause('visibility');
sa('scenarios').replaceChildren();
for (const [id, definition] of Object.entries(exhibit.scenarios)) {
const article = document.createElement('article'), label = document.createElement('strong'); label.textContent = definition.name ?? id; article.append(label);
const start = document.createElement('button'); start.textContent = 'Start'; start.onclick = () => saPerformance.scenarios.request(id);
const cancel = document.createElement('button'); cancel.textContent = 'Cancel'; cancel.onclick = () => { saPerformance.scenarios.cancel(id); saRefresh(); };
const output = document.createElement('output'); output.dataset.scenario = id;
article.append(start, cancel, output); sa('scenarios').append(article);
}
sa('event').disabled = !exhibit.events.request; sa('condition').disabled = !exhibit.state.armed;
sa('audio').disabled = false; sa('pause').textContent = 'Pause';
saLastFrame = undefined; saLastSecond = -1; saRefresh();
}
async function saUnlock() {
if (!saAudio) saAudio = new AudioSubsystem({ document: saPerformance.record.document, rng: saPerformance.rng, resolutionEngine: saPerformance.engine, diagnostics: saPerformance.diagnostics });
await saAudio.unlock(); saPerformance.setAudio(saAudio); saPerformance.scenarios.resumeContinuous();
sa('audio').disabled = saAudio.unlocked;
}
sa('audio').onclick = saUnlock;
saSelect.onchange = saRestart; sa('restart').onclick = saRestart;
sa('pause').onclick = async () => {
if (saPerformance.pauseReasons.has('user')) saPerformance.resume(); else saPerformance.pause();
if (saRun.mode === 'real-time-soak') saRun.visibilityInterruptions++;
if (saAudio?.context) { if (saPerformance.pauseReasons.size) await saAudio.context.suspend(); else await saAudio.context.resume(); }
sa('pause').textContent = saPerformance.pauseReasons.has('user') ? 'Resume' : 'Pause';
};
document.addEventListener('visibilitychange', async () => {
saLastFrame = undefined;
if (document.hidden) { saPerformance.pause('visibility'); if (saRun.mode === 'real-time-soak') saRun.visibilityInterruptions++; }
else saPerformance.resume('visibility');
if (saAudio?.context) { if (saPerformance.pauseReasons.size) await saAudio.context.suspend(); else await saAudio.context.resume(); }
});
sa('event').onclick = () => saPerformance.execute([{ type: 'event', event: 'request' }], { domain: 'manual-sample' });
sa('condition').onclick = () => {
const next = !saPerformance.engine.get('state.armed'); saPerformance.engine.setState('state.armed', next);
sa('condition').textContent = next ? 'Set condition false' : 'Set condition true';
};
sa('accelerate').onclick = async () => {
if (saAccelerating || saRun.mode === 'real-time-soak') return;
saAccelerating = true; saRun.mode = 'accelerated-logical-only';
// Batch ticks to yield UI control; no audio is produced or claimed during acceleration.
if (saAudio) { await saAudio.dispose(); saAudio = null; saPerformance.setAudio(null); }
const target = saPerformance.engine.logicalMilliseconds + 40 * 60 * 1000;
const locked = ['restart', 'fixture', 'soak', 'audio', 'accelerate'];
for (const id of locked) sa(id).disabled = true;
try {
while (saPerformance.engine.logicalMilliseconds < target) {
if (saPerformance.pauseReasons.size) break;
saPerformance.advanceTicks(300); saRefresh(); await new Promise(resolve => setTimeout(resolve, 0));
}
} finally { saAccelerating = false; for (const id of locked) sa(id).disabled = false; }
};
sa('soak').onclick = async () => {
saSelect.value = 'phase6-soak'; await saRestart(); await saUnlock();
if (!saAudio.unlocked) { sa('status').textContent = 'Audio unlock failed; soak has not started.'; return; }
saRun.mode = 'real-time-soak'; saRun.realStart = performance.now(); saRun.samples = []; saRun.memory = [];
saPerformance.scenarios.request('journey');
const run = saRun;
saAudio.captureOutput({ seconds: 7200, warmupSeconds: 30 }).then(result => { run.audio = result; saRefresh(); }, error => { run.audio = { error: error.message }; });
saRefresh();
};
sa('export').onclick = () => {
const bins = [...saRun.frames].sort((a, b) => a[0] - b[0]), total = bins.reduce((n, [, count]) => n + count, 0);
const percentile = fraction => { let n = 0; for (const [bucket, count] of bins) { n += count; if (n >= total * fraction) return bucket / 10; } return null; };
const report = { ...saRun, frames: bins, framePercentilesMs: { p50: percentile(.5), p95: percentile(.95), p99: percentile(.99) },
realElapsedMs: performance.now() - saRun.realStart, browser: navigator.userAgent, viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
canvas: { width: sa('canvas').width, height: sa('canvas').height }, sampleRate: saAudio?.context?.sampleRate ?? null,
environment: sa('environment').value, observations: sa('observations').value,
acceptance: 'Evidence only. Review duration, interruptions, memory trend, frame/audio measurements and listening observations against GC6; no automatic acceptance.' };
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }));
const link = document.createElement('a'); link.href = url; link.download = 'xzbt-phase6-evidence.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
};
void saRestart();
+465
View File
@@ -0,0 +1,465 @@
{
"xzbt": "0.1",
"meta": {
"id": "phase6-soak",
"name": "Phase 6 development soak",
"version": "1.0.0"
},
"runtime": {
"seed": 4206
},
"parameters": {
"activity": {
"type": "number",
"default": 0.35,
"min": 0,
"max": 1,
"label": "Activity"
}
},
"state": {
"mode": {
"type": "string",
"initial": "normal"
},
"beats": {
"type": "integer",
"initial": 0
},
"journeys": {
"type": "integer",
"initial": 0
}
},
"audio": {
"buses": {
"ambient": {
"gain": 0.5
},
"effects": {
"gain": 0.5
}
}
},
"sounds": {
"bed": {
"name": "Distant hum",
"bus": "ambient",
"usage": [
"automatic"
],
"cadence": {
"class": "ambient"
},
"recipe": {
"mode": "continuous",
"release": "1s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 55
},
"trim": {
"type": "gain",
"gain": 0.04
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"drone": {
"name": "Passing resonance",
"bus": "ambient",
"usage": [
"scenario"
],
"recipe": {
"mode": "continuous",
"release": "2s",
"nodes": {
"tone": {
"type": "oscillator",
"frequency": 165
},
"trim": {
"type": "gain",
"gain": 0.035
}
},
"routes": [
{
"from": "tone",
"to": "trim"
},
{
"from": "trim",
"to": "output"
}
]
}
},
"beat": {
"name": "Soft impulse",
"bus": "effects",
"usage": [
"automatic",
"scenario",
"manual"
],
"cadence": {
"class": "routine",
"overlap": false,
"cooldown": "3s"
},
"recipe": {
"mode": "oneshot",
"release": "50ms",
"nodes": {
"hit": {
"type": "impulse",
"duration": "8ms",
"amplitude": 0.06
}
},
"routes": [
{
"from": "hit",
"to": "output"
}
]
}
}
},
"cadence": {
"intensity": {
"ref": "parameters.activity"
},
"minGap": "1500ms"
},
"bindings": [
{
"source": "parameters.activity",
"target": "visuals.camera.zoom",
"scale": 0.2,
"offset": 1
}
],
"visuals": {
"scene": {
"coordinateSpace": "virtual",
"width": 960,
"height": 540,
"background": "#101d25"
},
"systems": {
"horizon": {
"type": "graphic",
"content": {
"ring": {
"type": "ring",
"radius": 150,
"innerRadius": 148,
"position": {
"x": 480,
"y": 270
},
"style": {
"fill": "#80beb5"
}
},
"line": {
"type": "line",
"position": {
"x": 240,
"y": 270
},
"to": {
"x": 480,
"y": 0
},
"style": {
"stroke": "#dfc18d",
"strokeWidth": 2
}
}
}
},
"signal": {
"type": "graphic",
"lifecycle": "spawned",
"spawn": {
"lifetime": "4s",
"release": "1s"
},
"content": {
"ring": {
"type": "ring",
"radius": 165,
"innerRadius": 160,
"position": {
"x": 480,
"y": 270
},
"style": {
"fill": "#efbe79"
},
"behaviors": [
{
"type": "rotate",
"speed": 12
}
]
}
}
}
}
},
"events": {
"signal": {
"actions": [
{
"type": "set",
"target": "state.beats",
"value": {
"op": "add",
"args": [
{
"ref": "state.beats"
},
1
]
}
},
{
"type": "sound",
"sound": "beat"
},
{
"type": "spawn",
"target": "visuals.systems.signal"
}
]
},
"recover": {
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
]
}
},
"scenarios": {
"journey": {
"name": "Long passage",
"priority": 70,
"group": "passage",
"trigger": {
"type": "random-interval",
"min": "45m",
"max": "60m"
},
"concurrency": {
"mode": "exclusive",
"scope": "group",
"policy": "defer"
},
"duration": "40m",
"cooldown": "5m",
"onStart": [
{
"type": "set",
"target": "state.journeys",
"value": {
"op": "add",
"args": [
{
"ref": "state.journeys"
},
1
]
}
},
{
"type": "set",
"target": "state.mode",
"value": "approach"
},
{
"type": "sound",
"sound": "drone"
},
{
"type": "override",
"target": "parameters.activity",
"value": 0.7,
"scope": "scenario",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
}
}
],
"timeline": [
{
"id": "arrival",
"at": "1m",
"actions": [
{
"type": "event",
"event": "signal"
}
]
},
{
"after": "arrival",
"delay": {
"random": {
"min": "20s",
"max": "50s"
}
},
"repeat": {
"count": 12,
"every": {
"random": {
"min": "1m",
"max": "2m"
}
}
},
"actions": [
{
"type": "event",
"event": "signal"
}
]
},
{
"at": "15m",
"choose": [
{
"weight": 3,
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "drift"
},
{
"type": "override",
"target": "parameters.activity",
"value": 0.5,
"scope": "duration",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
},
"duration": "5m"
}
]
},
{
"weight": 1,
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "disturbance"
},
{
"type": "override",
"target": "parameters.activity",
"value": 0.95,
"scope": "duration",
"transition": {
"in": "1s",
"out": "2s",
"easing": "ease-in-out"
},
"duration": "5m"
},
{
"type": "event",
"event": "signal"
}
]
}
]
},
{
"at": "30m",
"actions": [
{
"type": "event",
"event": "recover"
}
]
},
{
"at": "40m",
"actions": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
]
}
],
"onComplete": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
],
"onCancel": [
{
"type": "set",
"target": "state.mode",
"value": "normal"
}
]
},
"pulse": {
"trigger": {
"type": "interval",
"every": "20s"
},
"duration": "5s",
"onStart": [
{
"type": "event",
"event": "signal"
},
{
"type": "sound",
"sound": "drone"
},
{
"type": "override",
"target": "parameters.activity",
"value": 0.9,
"scope": "scenario",
"transition": {
"out": "2s"
}
}
],
"timeline": []
}
}
}
+547 -3
View File
@@ -404,10 +404,10 @@
},
"scenarios": {
"type": "object",
"description": "Autonomous orchestrated scenarios and timelines.",
"maxProperties": 64,
"patternProperties": {
"^[a-z][a-z0-9_-]*$": {
"type": "object"
"$ref": "#/definitions/ScenarioDefinition"
}
},
"additionalProperties": false
@@ -2141,8 +2141,14 @@
},
"transition": {
"type": "object"
},
"priority": {
"type": "integer",
"minimum": -1000,
"maximum": 1000
}
}
},
"additionalProperties": false
},
"EventDefinition": {
"type": "object",
@@ -3648,6 +3654,544 @@
}
},
"additionalProperties": false
},
"ScenarioTrigger": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"const": "manual"
}
},
"required": [
"type"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"const": "once"
},
"at": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
}
},
"required": [
"type"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"const": "interval"
},
"every": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
}
},
"required": [
"type",
"every"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"const": "random-interval"
},
"min": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
"max": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
}
},
"required": [
"type",
"min",
"max"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"const": "probability"
},
"every": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
"chance": {
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"required": [
"type",
"every",
"chance"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"const": "condition"
},
"when": {
"$ref": "#/definitions/ConditionSpec"
},
"for": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
}
},
"required": [
"type",
"when"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"const": "event"
},
"event": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
}
},
"required": [
"type",
"event"
]
}
]
},
"ScenarioEntry": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
},
"at": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
"after": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
},
"delay": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"random": {
"type": "object",
"additionalProperties": false,
"properties": {
"min": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
"max": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
}
},
"required": [
"min",
"max"
]
}
},
"required": [
"random"
]
}
]
},
"repeat": {
"type": "object",
"additionalProperties": false,
"properties": {
"count": {
"type": "integer",
"minimum": 1,
"maximum": 1024
},
"every": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"random": {
"type": "object",
"additionalProperties": false,
"properties": {
"min": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
"max": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
}
},
"required": [
"min",
"max"
]
}
},
"required": [
"random"
]
}
]
}
},
"required": [
"count",
"every"
]
},
"actions": {
"type": "array",
"items": {
"$ref": "#/definitions/ActionSpec"
}
},
"choose": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"weight": {
"type": "number",
"minimum": 0
},
"when": {
"$ref": "#/definitions/ConditionSpec"
},
"actions": {
"type": "array",
"items": {
"$ref": "#/definitions/ActionSpec"
}
}
},
"required": [
"weight",
"actions"
]
}
}
},
"allOf": [
{
"oneOf": [
{
"required": [
"at"
],
"not": {
"required": [
"after"
]
}
},
{
"required": [
"after"
],
"not": {
"required": [
"at"
]
}
}
]
},
{
"oneOf": [
{
"required": [
"actions"
],
"not": {
"required": [
"choose"
]
}
},
{
"required": [
"choose"
],
"not": {
"required": [
"actions"
]
}
}
]
}
]
},
"ScenarioDefinition": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"group": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
},
"trigger": {
"$ref": "#/definitions/ScenarioTrigger"
},
"eligibility": {
"type": "object",
"additionalProperties": false,
"properties": {
"when": {
"$ref": "#/definitions/ConditionSpec"
},
"timeout": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
}
}
},
"concurrency": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"enum": [
"parallel",
"exclusive"
]
},
"scope": {
"enum": [
"group",
"global"
]
},
"policy": {
"enum": [
"defer",
"reject",
"replace"
]
}
},
"required": [
"mode"
]
},
"cooldown": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
"duration": {
"anyOf": [
{
"$ref": "#/definitions/DurationSpec"
},
{
"type": "number",
"minimum": 0
}
]
},
"onStart": {
"type": "array",
"items": {
"$ref": "#/definitions/ActionSpec"
}
},
"onComplete": {
"type": "array",
"items": {
"$ref": "#/definitions/ActionSpec"
}
},
"onCancel": {
"type": "array",
"items": {
"$ref": "#/definitions/ActionSpec"
}
},
"timeline": {
"type": "array",
"maxItems": 1024,
"items": {
"$ref": "#/definitions/ScenarioEntry"
}
},
"tags": {
"type": "array",
"maxItems": 16,
"items": {
"type": "string",
"maxLength": 32
}
}
},
"required": [
"timeline"
]
}
}
}
+3 -1
View File
@@ -29,7 +29,7 @@
<span class="eyebrow">Active exhibit</span><h2 id="active-name"></h2>
<canvas id="stage-canvas" class="stage-canvas" aria-label="Exhibit visual output"></canvas>
<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. The audio engine realizes declared graphs, and the visual engine draws declared scenes, layers, primitives, and transforms; procedural systems, automation, and post-effects attach in later slices.</p>
<p>Declarative sound, visuals, cadence, and scenarios share one logical clock. Parameter edits remain stored while a temporary scenario override is active.</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="audio-title">
<h3 id="audio-title">Audio</h3>
@@ -44,6 +44,8 @@
<div id="sound-list" class="sound-list"></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>
<section class="grammar-panel" aria-labelledby="scenarios-title"><h3 id="scenarios-title">Scenarios</h3><div id="scenario-list"></div></section>
<button id="pause-button" type="button">Pause</button>
<button id="deactivate-button" type="button">Deactivate</button>
</div>
</section>
+15 -3
View File
@@ -85,9 +85,10 @@ export class ActionExecutor {
execute(actions, context = {}) {
if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.');
const domain = context.domain ?? 'scenario';
const stream = this.engine.rng.stream(domain, `actions:${++this.invocationOrdinal}`);
const stream = context.stream ?? this.engine.rng.stream(domain, `actions:${++this.invocationOrdinal}`);
const results = [];
for (let index = 0; index < actions.length; index += 1) {
if (!context.isTerminationHook && context.owner?.startsWith('instances.scenario-') && this.scenarios && !this.scenarios.active.has(context.owner)) break;
const action = actions[index];
this.consumeUnit(context);
try {
@@ -96,7 +97,7 @@ export class ActionExecutor {
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;
if (action?.critical !== false || fault.code === 'ERR_DISPATCH_BUDGET') throw fault;
}
}
return results;
@@ -104,12 +105,16 @@ export class ActionExecutor {
executeOne(action, stream, context, path) {
if (!isRecord(action) || typeof action.type !== 'string') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Action requires a type.', path);
if (context.isTerminationHook && (!['set', 'sound'].includes(action.type) || ['persistent', 'performance'].includes(action.ownership))) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Unsupported termination-hook action.', path);
if (action.when !== undefined && !this.evaluateCondition(action.when, stream, `${path}.when`, context.inputScope)) 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 === 'control' && this.scenarios) {
return this.scenarios.control(action.target?.replace(/^scenarios\./, ''), action.command, context);
}
if (action.type === 'set') {
const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope);
this.engine.setState(action.target, value, action.transition);
@@ -167,6 +172,7 @@ export class ActionExecutor {
}
this.diagnostics?.info?.('INFO_EVENT_INVOKED', `Event '${action.event}' invoked.`, { exhibitId: this.engine.document.meta?.id, section: 'events', objectId: action.event });
this.scenarios?.notifyEvent(action.event, { ...context, inputScope });
const results = this.execute(eventDef.actions, {
...context,
depth,
@@ -188,7 +194,13 @@ export class ActionExecutor {
}
const inputs = action.with ? Object.fromEntries(Object.entries(action.with).map(([k, v]) => [k, this.evaluateValue(v, stream, `${path}.with.${k}`, context.inputScope)])) : undefined;
const audio = this.audio ?? this.engine.audio;
const voice = audio ? audio.play(soundId, { inputs, owner: action.ownership ?? context.owner }) : null;
const recipe = targetSound.recipe?.use ? this.engine.document.audio?.recipes?.[targetSound.recipe.use] : targetSound.recipe;
if (context.isTerminationHook && (recipe?.mode ?? 'oneshot') !== 'oneshot') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Termination sounds must be one-shot.', path);
const owner = ['persistent', 'performance'].includes(action.ownership) ? 'performance' : context.owner ?? 'performance';
if ((!audio || !audio.unlocked) && recipe?.mode === 'continuous' && this.scenarios && !context.isTerminationHook) {
return this.scenarios.deferContinuous(soundId, { inputs, owner });
}
const voice = audio ? audio.play(soundId, { inputs, owner }) : null;
return { status: voice ? 'executed' : 'refused', type: 'sound', sound: soundId, voiceId: voice?.id ?? null };
}
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Action '${action.type}' belongs to a later subsystem phase.`, `${path}.type`);
+46 -1
View File
@@ -76,6 +76,19 @@ export class XZBTApplication {
}
bindEvents() {
element('pause-button').addEventListener('click', async () => {
const p = this.activation.current?.performance;
if (!p) return;
if (p.pauseReasons.has('user')) p.resume(); else p.pause();
element('pause-button').textContent = p.pauseReasons.has('user') ? 'Resume' : 'Pause';
await this.syncAudioPause();
});
document.addEventListener('visibilitychange', async () => {
const p = this.activation.current?.performance;
if (!p) return;
if (document.hidden) p.pause('visibility'); else p.resume('visibility');
await this.syncAudioPause();
});
element('import-files').addEventListener('change', async (event) => {
await this.importFiles([...event.target.files]);
event.target.value = '';
@@ -155,9 +168,9 @@ export class XZBTApplication {
if (this.busy || !this.activation.current) return;
this.setBusy(true, 'Deactivating exhibit…');
try {
await this.activation.deactivate();
await this.disposeAudio();
this.visual.deactivate();
await this.activation.deactivate();
this.renderLibrary();
this.renderStage();
} finally {
@@ -234,6 +247,32 @@ export class XZBTApplication {
this.renderConfiguration(current.performance);
this.renderValues(current.performance.engine);
this.renderAudio();
this.renderScenarios();
}
async syncAudioPause() {
const context = this.audio?.context;
if (!context) return;
try {
if (this.activation.current?.performance.pauseReasons.size) await context.suspend();
else if (context.state === 'suspended') await context.resume();
} catch (error) { this.diagnostics.warn('WARN_AUDIO_UNAVAILABLE', error.message, { section: 'audio' }); }
}
renderScenarios() {
const container = element('scenario-list');
container.replaceChildren();
const director = this.activation.current?.performance.scenarios;
for (const [id, state] of director?.definitions ?? []) {
const row = document.createElement('div');
row.append(text('span', state.definition.name ?? id));
const start = text('button', 'Start'); start.type = 'button'; start.onclick = () => director.request(id);
const stop = text('button', 'Cancel'); stop.type = 'button'; stop.onclick = () => director.cancel(id);
const status = text('output', state.status); status.dataset.scenarioStatus = id;
row.append(start, stop, status); container.append(row);
}
if (!director?.definitions.size) container.append(text('p', 'No scenarios in this exhibit.'));
element('pause-button').textContent = 'Pause';
}
async unlockAudio() {
@@ -250,6 +289,8 @@ export class XZBTApplication {
}
await this.audio.unlock();
current.performance.setAudio(this.audio);
current.performance.scenarios.resumeContinuous();
await this.syncAudioPause();
this.renderAudio();
}
@@ -409,6 +450,10 @@ export class XZBTApplication {
}
renderValues(engine) {
for (const output of document.querySelectorAll('[data-scenario-status]')) {
const state = this.activation.current?.performance.scenarios.definitions.get(output.dataset.scenarioStatus);
if (state) output.textContent = state.status;
}
const container = element('resolved-values');
if (!container) return;
const snapshot = engine.snapshot();
+17 -2
View File
@@ -593,6 +593,7 @@ export class SoundInstance {
if (this.subsystem) {
this.subsystem.voices.delete(this);
}
if (this.subsystem?.useLogicalClock) this.logicalReleaseAt = this.subsystem.resolution.logicalMilliseconds;
if (this.state === 'CREATED') {
this.transition('FINISHED');
return;
@@ -601,7 +602,7 @@ export class SoundInstance {
this.transition('RELEASING');
if (this.releaseMs === 0) {
this.transition('FINISHED');
} else {
} else if (!this.subsystem?.useLogicalClock) {
this.releaseTimer = setTimeout(() => {
if (this.state === 'RELEASING') {
this.transition('FINISHED');
@@ -633,6 +634,7 @@ export class SoundInstance {
gainParam.setValueAtTime?.(gainParam.value, now);
gainParam.linearRampToValueAtTime?.(0, now + durationSec);
}
if (this.subsystem?.useLogicalClock) return;
this.releaseTimer = setTimeout(() => {
if (this.state === 'RELEASING') {
this.transition('FINISHED');
@@ -983,6 +985,7 @@ export class AudioSubsystem {
instance.realize(this.context, this.busFor(soundId));
instance.transition('ACTIVE');
instance.startTime = instance.realized.startTime;
instance.logicalStartedAt = this.resolution.logicalMilliseconds;
this.voices.add(instance);
} catch (error) {
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
@@ -991,7 +994,7 @@ export class AudioSubsystem {
return null;
}
if (mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
if (!this.useLogicalClock && mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
instance.endingTimer = setTimeout(() => {
if (instance.state === 'ACTIVE') {
this.voices.delete(instance);
@@ -1008,6 +1011,18 @@ export class AudioSubsystem {
for (const instance of [...this.voices]) instance.stop();
}
advanceLogical() {
if (!this.useLogicalClock) return;
const now = this.resolution.logicalMilliseconds;
for (const voice of [...this.oneshotVoices, ...this.continuousVoices]) {
if (voice.state === 'ACTIVE' && voice.mode === 'oneshot' && Number.isFinite(voice.endingBoundMs) && now - voice.logicalStartedAt >= voice.endingBoundMs) {
this.voices.delete(voice); voice.transition('FINISHED');
}
if (voice.state === 'RELEASING' && now - voice.logicalReleaseAt >= voice.releaseMs) voice.transition('FINISHED');
if (voice.state === 'FINISHED') voice.dispose();
}
}
async dispose() {
this.disposed = true;
this.ready = false;
+1 -1
View File
@@ -1,5 +1,5 @@
export const XZBT_FORMAT_VERSION = '0.1';
export const XZBT_RUNTIME_VERSION = '0.1.0-phase4-stage0';
export const XZBT_RUNTIME_VERSION = '0.1.0-phase6';
export const UINT32_RANGE = 0x1_0000_0000;
export const RNG_DOMAINS = Object.freeze([
'cadence',
+34 -4
View File
@@ -2,6 +2,7 @@ import { ActionExecutor } from './actions.js';
import { SeededRNG } from './rng.js';
import { ResolutionEngine } from './resolution.js';
import { CadenceSubsystem } from './cadence.js';
import { ScenarioDirector } from './scenario.js';
export class CommonGrammarPerformance {
constructor(record, rootSeed, options = {}) {
@@ -22,6 +23,9 @@ export class CommonGrammarPerformance {
onParameterChange: options.onParameterChange
});
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
this.actions.audio = options.audio ?? null;
if (this.actions.audio) this.actions.audio.useLogicalClock = true;
this.scenarios = new ScenarioDirector({ document: record.document, resolution: this.engine, actions: this.actions, rng: this.rng, diagnostics: this.diagnostics, onTrace: options.onScenarioTrace });
this.cadence = new CadenceSubsystem({
document: record.document,
rng: this.rng,
@@ -34,6 +38,7 @@ export class CommonGrammarPerformance {
this.frameRequest = null;
this.lastFrame = undefined;
this.accumulator = 0;
this.pauseReasons = new Set();
this.boundFrame = (now) => this.frame(now);
this.boundPointer = (event) => {
this.engine.signals.set('signals.pointer.x', event.clientX);
@@ -64,14 +69,16 @@ export class CommonGrammarPerformance {
const observed = Math.max(0, now - this.lastFrame);
this.lastFrame = now;
const accepted = Math.min(observed, 250);
if (this.pauseReasons.size) {
this.onFrame(this.engine);
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
return;
}
this.accumulator += accepted;
const step = 1000 / 60;
let ticks = 0;
while (this.accumulator + 1e-9 >= step && ticks < 8) {
this.actions.resetBudget();
this.engine.advance(step);
this.cadence.advance(step);
this.onTick(step, this.engine);
this.tick();
this.accumulator -= step;
ticks += 1;
}
@@ -86,9 +93,30 @@ export class CommonGrammarPerformance {
setAudio(audio) {
this.actions.audio = audio;
if (audio) audio.useLogicalClock = true;
this.cadence.setAudio(audio);
}
tick() {
if (this.pauseReasons.size || this.scenarios.disposed) return;
const step = 1000 / 60;
this.actions.resetBudget();
this.engine.advance(step);
this.actions.audio?.advanceLogical?.();
this.cadence.advance(step);
this.scenarios.update();
this.onTick(step, this.engine);
}
// Development acceleration executes the same fixed ticks without rendering.
advanceTicks(count) {
if (!Number.isSafeInteger(count) || count < 0) throw new RangeError('Tick count must be a nonnegative safe integer.');
for (let i = 0; i < count; i++) this.tick();
}
pause(reason = 'user') { this.pauseReasons.add(reason); this.lastFrame = undefined; this.accumulator = 0; }
resume(reason = 'user') { this.pauseReasons.delete(reason); this.lastFrame = undefined; this.accumulator = 0; }
setParameter(id, value) {
const result = this.engine.setParameter(id, value);
this.onUpdate(this.engine);
@@ -109,6 +137,7 @@ export class CommonGrammarPerformance {
}
async deactivate() {
this.scenarios.dispose();
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
this.lastFrame = undefined;
@@ -122,6 +151,7 @@ export class CommonGrammarPerformance {
}
async dispose() {
this.scenarios.dispose();
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
if (typeof globalThis.removeEventListener === 'function') {
+4 -1
View File
@@ -112,7 +112,10 @@ export class OverrideStack {
}
releaseOwner(owner) {
for (const instance of [...this.instances.values()]) if (instance.owner === owner && instance.scope === 'scenario') this.beginRelease(instance.id);
for (const instance of [...this.instances.values()]) if (instance.owner === owner) {
instance.releaseMs = Math.min(instance.releaseMs, 5000);
this.beginRelease(instance.id);
}
}
advance() {
+201
View File
@@ -0,0 +1,201 @@
import { ID_PATTERN } from './constants.js';
import { isRecord, parseDuration, valueMatchesType, EASINGS } from './types.js';
import { matchVisualTarget } from './visual-contract.js';
export const SCENARIO_LIMITS = Object.freeze({ definitions: 64, active: 16, entries: 1024, repeats: 1024, cleanupMs: 5000 });
// Both import paths use this validator; no clocks or resources are created here.
export function validateScenarioSubsystem(document, errors, helpers = {}) {
const fail = (code, path, message) => errors.push({ code, path, message });
const shape = (value, fields, path) => {
if (!isRecord(value)) { fail('ERR_SCHEMA_VALIDATION', path, 'Expected an object.'); return false; }
for (const key of Object.keys(value)) if (!fields.includes(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Unknown field '${key}'.`);
return true;
};
const time = (value, path, positive = false, random = false) => {
if (random && isRecord(value)) {
if (!shape(value, ['random'], path) || !shape(value.random, ['min', 'max'], `${path}.random`)) return;
const min = time(value.random.min, `${path}.random.min`, positive), max = time(value.random.max, `${path}.random.max`, positive);
if (min > max) fail('ERR_INVALID_RANGE_ORDER', path, 'Time range is reversed.');
return;
}
try {
const ms = parseDuration(value, path);
if (!Number.isFinite(ms) || (positive && ms <= 0)) throw new Error('Duration must be finite and positive.');
return ms;
} catch (error) { fail('ERR_INVALID_DURATION', path, error.message); }
};
const condition = (value, path) => helpers.validateCondition?.(document, value, path, errors);
const value = (spec, path, inputs = {}) => helpers.validateValueSpec?.(document, spec, path, errors, { inputs, componentParameters: new Map(Object.entries(inputs)) });
const fields = {
set: ['target', 'value', 'transition'], override: ['target', 'value', 'scope', 'duration', 'priority', 'transition'],
event: ['event', 'with'], sound: ['sound', 'with', 'ownership'], spawn: ['target', 'with', 'ownership', 'lifetime'],
remove: ['target'], control: ['target', 'command']
};
const soundMode = sound => (sound?.recipe?.use ? document.audio?.recipes?.[sound.recipe.use]?.mode : sound?.recipe?.mode) ?? 'oneshot';
const actions = (list, path, hook = false, inputs = {}, scenario = true) => {
if (!Array.isArray(list)) { fail('ERR_SCHEMA_VALIDATION', path, 'Actions must be an array.'); return; }
list.forEach((action, index) => {
const p = `${path}[${index}]`;
if (!isRecord(action) || !fields[action.type]) { fail('ERR_SCHEMA_VALIDATION', p, 'Unsupported action type.'); return; }
shape(action, ['type', 'id', 'when', 'chance', 'critical', ...fields[action.type]], p);
if (action.id !== undefined && !ID_PATTERN.test(action.id)) fail('ERR_INVALID_ID', `${p}.id`, 'Invalid action ID.');
if (action.critical !== undefined && typeof action.critical !== 'boolean') fail('ERR_TYPE_MISMATCH', p, 'critical must be boolean.');
if (action.chance !== undefined && (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1)) fail('ERR_OUT_OF_BOUNDS', p, 'chance must be in [0,1].');
if (action.when !== undefined) condition(action.when, `${p}.when`);
if (hook && (!['set', 'sound'].includes(action.type) || (action.type === 'sound' && (soundMode(document.sounds?.[action.sound]) !== 'oneshot' || ['persistent', 'performance'].includes(action.ownership))))) fail('ERR_UNSUPPORTED_TARGET', p, 'Termination hooks permit only set and nonpersistent one-shot sound actions.');
if (['set', 'override'].includes(action.type)) {
const target = action.target;
const visual = matchVisualTarget(document, target);
const spec = typeof target === 'string' ? target.startsWith('state.') ? document.state?.[target.slice(6)] : target.startsWith('parameters.') ? document.parameters?.[target.slice(11)] : /^audio\.buses\.[^.]+\.gain$/.test(target) ? document.audio?.buses?.[target.split('.')[2]] && { type: 'number' } : visual && !visual.reason ? visual.spec : null : null;
if (!spec || (action.type === 'set' && !target.startsWith('state.'))) fail('ERR_UNSUPPORTED_TARGET', `${p}.target`, 'Target is not exposed for this action.');
value(action.value, `${p}.value`, inputs);
if (spec && !isRecord(action.value) && !valueMatchesType(spec.type, action.value, spec)) fail('ERR_TYPE_MISMATCH', `${p}.value`, 'Action value has the wrong type.');
if (action.type === 'override') {
if (!['scenario', 'duration'].includes(action.scope)) fail('ERR_SCHEMA_VALIDATION', p, 'Override scope is required.');
if (action.scope === 'duration') time(action.duration, `${p}.duration`, true);
else if (action.duration !== undefined) fail('ERR_SCHEMA_VALIDATION', p, 'Scenario override cannot declare duration.');
if (action.priority !== undefined && (!Number.isInteger(action.priority) || Math.abs(action.priority) > 1000)) fail('ERR_OUT_OF_BOUNDS', p, 'Override priority must be -1000 through 1000.');
}
if (action.transition !== undefined && shape(action.transition, action.type === 'set' ? ['duration', 'easing'] : ['in', 'out', 'easing'], `${p}.transition`)) {
for (const key of ['duration', 'in', 'out']) if (action.transition[key] !== undefined) time(action.transition[key], `${p}.transition.${key}`);
if (action.transition.easing !== undefined && !EASINGS.includes(action.transition.easing)) fail('ERR_INVALID_TRANSITION', p, 'Unknown easing.');
}
}
if (action.type === 'event' || action.type === 'sound') {
const def = action.type === 'event' ? document.events?.[action.event] : document.sounds?.[action.sound];
if (!def) fail('ERR_INVALID_REFERENCE', p, 'Unknown event or sound.');
if (scenario && action.type === 'sound' && def && !(def.usage ?? ['automatic']).includes('scenario')) fail('ERR_UNSUPPORTED_TARGET', p, 'Sound does not permit scenario usage.');
}
if (action.type === 'spawn') {
const def = document.visuals?.systems?.[action.target?.replace(/^visuals\.systems\./, '')];
if (!def || def.lifecycle !== 'spawned') fail('ERR_INVALID_REFERENCE', p, 'Spawn requires a spawned visual template.');
if (action.ownership === 'persistent' && def?.spawn?.ownership !== 'persistent') fail('ERR_UNSUPPORTED_TARGET', p, 'Template does not permit persistent ownership.');
if (action.lifetime !== undefined) value(action.lifetime, `${p}.lifetime`, inputs);
}
if (action.type === 'remove' && (typeof action.target !== 'string' || !action.target.startsWith('instances.'))) fail('ERR_UNSUPPORTED_TARGET', p, 'remove requires an instance ID.');
if (action.ownership !== undefined && !['scenario', 'persistent', ...(action.type === 'sound' ? ['performance'] : [])].includes(action.ownership)) fail('ERR_UNSUPPORTED_TARGET', p, 'Unsupported resource ownership.');
if (action.with !== undefined) {
if (isRecord(action.with)) for (const [key, spec] of Object.entries(action.with)) value(spec, `${p}.with.${key}`, inputs);
else fail('ERR_SCHEMA_VALIDATION', `${p}.with`, 'with must be an object.');
}
if (action.type === 'control') {
if (!/^scenarios\.[a-z][a-z0-9_-]*$/.test(action.target) || !document.scenarios?.[action.target?.slice(10)]) fail('ERR_INVALID_REFERENCE', p, 'Unknown scenario control target.');
if (!['start', 'stop', 'enable', 'disable'].includes(action.command)) fail('ERR_UNSUPPORTED_TARGET', p, 'Scenario controls support start, stop, enable, disable.');
}
});
};
// Strict action checking also closes the event-to-scenario dispatch boundary.
for (const [id, event] of Object.entries(document.events ?? {})) if (isRecord(event) && Array.isArray(event.actions)) actions(event.actions, `$.events.${id}.actions`, false, event.inputs, false);
if (document.scenarios === undefined) return;
if (!isRecord(document.scenarios)) { fail('ERR_SCHEMA_VALIDATION', '$.scenarios', 'scenarios must be an object.'); return; }
const definitions = Object.entries(document.scenarios);
if (definitions.length > SCENARIO_LIMITS.definitions) fail('ERR_OUT_OF_BOUNDS', '$.scenarios', 'At most 64 scenario definitions.');
for (const [id, def] of definitions) {
const p = `$.scenarios.${id}`;
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', p, 'Invalid scenario ID.');
if (!shape(def, ['name', 'enabled', 'priority', 'group', 'trigger', 'eligibility', 'concurrency', 'cooldown', 'duration', 'onStart', 'timeline', 'onComplete', 'onCancel', 'tags'], p)) continue;
if (def.name !== undefined && typeof def.name !== 'string') fail('ERR_TYPE_MISMATCH', `${p}.name`, 'name must be a string.');
if (def.enabled !== undefined && typeof def.enabled !== 'boolean') fail('ERR_TYPE_MISMATCH', `${p}.enabled`, 'enabled must be boolean.');
if (def.priority !== undefined && (!Number.isInteger(def.priority) || def.priority < 0 || def.priority > 100)) fail('ERR_OUT_OF_BOUNDS', `${p}.priority`, 'Scenario priority must be 0 through 100.');
if (def.group !== undefined && (typeof def.group !== 'string' || !ID_PATTERN.test(def.group))) fail('ERR_INVALID_ID', `${p}.group`, 'Invalid group ID.');
if (def.tags !== undefined && (!Array.isArray(def.tags) || def.tags.length > 16 || def.tags.some(t => typeof t !== 'string' || t.length > 32))) fail('ERR_TYPE_MISMATCH', `${p}.tags`, 'tags must contain at most 16 short strings.');
if (def.duration !== undefined) time(def.duration, `${p}.duration`, true);
if (def.cooldown !== undefined) time(def.cooldown, `${p}.cooldown`);
if (def.eligibility !== undefined && shape(def.eligibility, ['when', 'timeout'], `${p}.eligibility`)) {
if (def.eligibility.when !== undefined) condition(def.eligibility.when, `${p}.eligibility.when`);
if (def.eligibility.timeout !== undefined) time(def.eligibility.timeout, `${p}.eligibility.timeout`, true);
}
if (def.concurrency !== undefined && shape(def.concurrency, ['mode', 'scope', 'policy'], `${p}.concurrency`)) {
if (!['parallel', 'exclusive'].includes(def.concurrency.mode)) fail('ERR_SCHEMA_VALIDATION', `${p}.concurrency`, 'Expected parallel or exclusive mode.');
if (def.concurrency.scope !== undefined && !['group', 'global'].includes(def.concurrency.scope)) fail('ERR_SCHEMA_VALIDATION', p, 'Expected group or global scope.');
if (def.concurrency.policy !== undefined && !['defer', 'reject', 'replace'].includes(def.concurrency.policy)) fail('ERR_SCHEMA_VALIDATION', p, 'Expected defer, reject or replace policy.');
if (def.concurrency.scope === 'group' && !def.group) fail('ERR_SCHEMA_VALIDATION', p, 'Group scope requires group.');
}
const trigger = def.trigger ?? { type: 'manual' };
const triggerFields = { manual: [], once: ['at'], interval: ['every'], 'random-interval': ['min', 'max'], probability: ['every', 'chance'], condition: ['when', 'for'], event: ['event'] };
if (!isRecord(trigger) || !triggerFields[trigger.type]) fail('ERR_SCHEMA_VALIDATION', `${p}.trigger`, 'Unknown trigger type.');
else {
shape(trigger, ['type', ...triggerFields[trigger.type]], `${p}.trigger`);
if (trigger.type === 'once') time(trigger.at ?? '0ms', `${p}.trigger.at`);
if (['interval', 'probability'].includes(trigger.type)) time(trigger.every, `${p}.trigger.every`, true);
if (trigger.type === 'random-interval') time({ random: { min: trigger.min, max: trigger.max } }, `${p}.trigger`, true, true);
if (trigger.type === 'probability' && (!Number.isFinite(trigger.chance) || trigger.chance < 0 || trigger.chance > 1)) fail('ERR_OUT_OF_BOUNDS', p, 'Trigger chance must be in [0,1].');
if (trigger.type === 'condition') { condition(trigger.when, `${p}.trigger.when`); time(trigger.for ?? '0ms', `${p}.trigger.for`); }
if (trigger.type === 'event' && !document.events?.[trigger.event]) fail('ERR_INVALID_REFERENCE', p, 'Unknown trigger event.');
}
for (const key of ['onStart', 'onComplete', 'onCancel']) if (def[key] !== undefined) actions(def[key], `${p}.${key}`, key !== 'onStart');
if (!Array.isArray(def.timeline)) { fail('ERR_SCHEMA_VALIDATION', `${p}.timeline`, 'timeline is required.'); continue; }
if (def.timeline.length > SCENARIO_LIMITS.entries) fail('ERR_OUT_OF_BOUNDS', `${p}.timeline`, 'At most 1024 timeline entries.');
const ids = new Map();
def.timeline.forEach((entry, index) => {
const ep = `${p}.timeline[${index}]`;
if (!shape(entry, ['id', 'at', 'after', 'delay', 'repeat', 'actions', 'choose'], ep)) return;
if (entry.id !== undefined) {
if (!ID_PATTERN.test(entry.id) || ids.has(entry.id)) fail('ERR_INVALID_ID', ep, 'Timeline IDs must be valid and unique.');
ids.set(entry.id, index);
}
if ((entry.at !== undefined) === (entry.after !== undefined)) fail('ERR_SCHEMA_VALIDATION', ep, 'Use exactly one of at and after.');
if (entry.at !== undefined) { time(entry.at, `${ep}.at`); if (entry.delay !== undefined) fail('ERR_UNKNOWN_FIELD', `${ep}.delay`, 'delay requires after.'); }
else time(entry.delay ?? '0ms', `${ep}.delay`, false, true);
if (entry.repeat !== undefined && shape(entry.repeat, ['count', 'every'], `${ep}.repeat`)) {
if (!Number.isInteger(entry.repeat.count) || entry.repeat.count < 1 || entry.repeat.count > SCENARIO_LIMITS.repeats) fail('ERR_OUT_OF_BOUNDS', ep, 'Repeat count must be 1 through 1024, including the first beat.');
time(entry.repeat.every, `${ep}.repeat.every`, true, true);
}
if ((entry.actions !== undefined) === (entry.choose !== undefined)) fail('ERR_SCHEMA_VALIDATION', ep, 'Use exactly one of actions and choose.');
if (entry.actions !== undefined) actions(entry.actions, `${ep}.actions`);
if (entry.choose !== undefined) {
if (!Array.isArray(entry.choose) || !entry.choose.length) fail('ERR_SCHEMA_VALIDATION', ep, 'choose requires branches.');
else entry.choose.forEach((branch, bi) => {
const bp = `${ep}.choose[${bi}]`;
if (!shape(branch, ['weight', 'when', 'actions'], bp)) return;
if (!Number.isFinite(branch.weight) || branch.weight < 0) fail('ERR_OUT_OF_BOUNDS', bp, 'Branch weight must be finite and nonnegative.');
if (branch.when !== undefined) condition(branch.when, `${bp}.when`);
actions(branch.actions, `${bp}.actions`);
});
}
});
const visited = new Set(), visiting = new Set();
const visit = index => {
if (visiting.has(index)) { fail('ERR_CYCLIC_DEPENDENCY', `${p}.timeline`, 'Relative timing cycle.'); return; }
if (visited.has(index)) return;
visiting.add(index);
const entry = def.timeline[index];
if (entry?.after !== undefined) {
if (!ids.has(entry.after)) fail('ERR_INVALID_REFERENCE', `${p}.timeline[${index}].after`, 'Unknown timeline anchor.');
else visit(ids.get(entry.after));
}
visiting.delete(index); visited.add(index);
};
def.timeline.forEach((_, i) => visit(i));
}
// Include indirect event invocation and event-triggered starts in the same graph.
const graph = new Map();
const scan = (node, list) => {
for (const action of Array.isArray(list) ? list : []) {
if (action?.type === 'event') graph.get(node).add(`event:${action.event}`);
if (action?.type === 'control' && action.command === 'start') graph.get(node).add(`scenario:${action.target?.slice(10)}`);
}
};
for (const id of Object.keys(document.events ?? {})) graph.set(`event:${id}`, new Set());
for (const [id] of definitions) graph.set(`scenario:${id}`, new Set());
for (const [id, event] of Object.entries(document.events ?? {})) scan(`event:${id}`, event?.actions);
for (const [id, def] of definitions) {
if (def?.trigger?.type === 'event') graph.get(`event:${def.trigger.event}`)?.add(`scenario:${id}`);
const node = `scenario:${id}`;
scan(node, def?.onStart);
for (const entry of Array.isArray(def?.timeline) ? def.timeline : []) {
scan(node, entry?.actions);
for (const branch of Array.isArray(entry?.choose) ? entry.choose : []) scan(node, branch?.actions);
}
}
const done = new Set(), stack = new Set();
const visit = node => {
if (stack.has(node)) { fail('ERR_CYCLIC_DEPENDENCY', '$.scenarios', `Event/scenario feedback at ${node}.`); return; }
if (done.has(node)) return;
stack.add(node);
for (const child of graph.get(node) ?? []) visit(child);
stack.delete(node); done.add(node);
};
for (const node of graph.keys()) visit(node);
}
+350
View File
@@ -0,0 +1,350 @@
import { RuntimeFault, parseDuration } from './types.js';
import { SCENARIO_LIMITS } from './scenario-validation.js';
const SCENARIO_EPSILON = 1e-6;
export class ScenarioDirector {
constructor({ document, resolution, actions, rng, diagnostics = null, onTrace = null }) {
Object.assign(this, { document, resolution, actions, rng, diagnostics, onTrace });
this.active = new Map();
this.pending = new Map();
this.cleanups = new Map();
this.continuousRequests = [];
this.definitions = new Map();
this.disposed = false;
this.now = resolution.logicalMilliseconds;
let order = 0;
for (const [id, definition] of Object.entries(document.scenarios ?? {})) {
const trigger = definition.trigger ?? { type: 'manual' };
const stream = rng.stream('scenario', `${id}:trigger:1`);
const state = { id, definition, trigger, order: order++, priority: definition.priority ?? 50,
enabled: definition.enabled !== false, ordinal: 0, lastEnd: -Infinity, stream,
conditionState: 'disarmed', holdAt: null, next: Infinity, status: 'DEFINED', lastState: null };
if (trigger.type === 'once') state.next = parseDuration(trigger.at ?? '0ms');
if (['interval', 'probability'].includes(trigger.type)) state.next = parseDuration(trigger.every);
if (trigger.type === 'random-interval') state.next = this.sampleTime({ random: trigger }, stream);
this.definitions.set(id, state);
}
actions.scenarios = this;
}
sampleTime(spec, stream) {
if (typeof spec === 'string' || typeof spec === 'number') return parseDuration(spec);
const min = parseDuration(spec.random.min), max = parseDuration(spec.random.max);
return min + stream.nextFloat() * (max - min);
}
trace(type, instance, details = {}) {
this.onTrace?.({ type, time: this.now, scenario: instance.definitionId ?? instance.id, instance: instance.owner ?? null, ...details });
}
audioVoices() {
const audio = this.actions.audio;
return [...new Set([...(audio?.voices ?? []), ...(audio?.oneshotVoices ?? []), ...(audio?.continuousVoices ?? [])])];
}
deferContinuous(soundId, options) {
if (this.continuousRequests.length >= 16) return { status: 'refused', type: 'sound' };
this.continuousRequests.push({ soundId, options });
return { status: 'scheduled', type: 'sound' };
}
resumeContinuous() {
if (!this.actions.audio?.unlocked) return;
for (const request of this.continuousRequests.splice(0)) this.actions.audio.play(request.soundId, request.options);
}
eligible(state) {
return state.enabled && this.now + SCENARIO_EPSILON >= state.lastEnd + parseDuration(state.definition.cooldown ?? '0ms')
&& (!state.definition.eligibility?.when || this.resolution.evaluateCondition(state.definition.eligibility.when, state.stream));
}
conflicts(state) {
return [...this.active.values()].filter(instance => {
if (instance.definitionId === state.id) return true;
const other = this.definitions.get(instance.definitionId);
const a = state.definition.concurrency ?? {}, b = other.definition.concurrency ?? {};
const blocks = (config, left, right) => config.mode === 'exclusive'
&& ((config.scope ?? 'global') === 'global' || left.definition.group === right.definition.group);
return blocks(a, state, other) || blocks(b, other, state);
});
}
request(id, context = {}) {
if (this.disposed) return { status: 'refused' };
const state = this.definitions.get(id);
if (!state) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown scenario '${id}'.`);
if (this.pending.has(id)) return { status: 'deferred' };
this.pending.set(id, { id, created: this.now, expires: this.now + parseDuration(state.definition.eligibility?.timeout ?? '5m'),
sourceOwner: context.owner ?? 'performance', triggerInputs: context.inputScope ? { ...context.inputScope } : null, attempted: false });
if (![...this.active.values()].some(i => i.definitionId === id)) state.status = 'SCHEDULED';
return { status: 'scheduled' };
}
notifyEvent(id, context = {}) {
if (this.disposed || context.isTerminationHook) return;
for (const state of this.definitions.values()) if (state.trigger.type === 'event' && state.trigger.event === id) this.request(state.id, context);
}
control(id, command, context = {}) {
const state = this.definitions.get(id);
if (!state) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown scenario '${id}'.`);
if (command === 'start') return this.request(id, context);
if (command === 'stop') { this.cancel(id); return { status: 'executed' }; }
if (command === 'enable' || command === 'disable') {
state.enabled = command === 'enable';
return { status: 'executed' };
}
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Unsupported scenario command '${command}'.`);
}
cancel(id) {
this.pending.delete(id);
for (const instance of [...this.active.values()]) if (instance.definitionId === id || instance.owner === id) this.terminate(instance, 'CANCELLED');
}
sampleTimeline(state, stream) {
const entries = state.definition.timeline;
const byId = new Map(entries.map((entry, i) => [entry.id, i]));
const times = new Map();
const at = index => {
if (times.has(index)) return times.get(index);
const entry = entries[index];
const due = entry.at !== undefined ? parseDuration(entry.at) : at(byId.get(entry.after)) + this.sampleTime(entry.delay ?? '0ms', stream);
times.set(index, due); return due;
};
return entries.map((entry, index) => ({ entry, index, due: at(index), beat: 0 }));
}
start(state) {
const ordinal = ++state.ordinal;
const owner = `instances.scenario-${state.id}-${ordinal}`;
const instance = { owner, definitionId: state.id, priority: state.priority, order: state.order, started: this.now,
stream: this.rng.stream('scenario', `${state.id}:${ordinal}`), state: 'STARTING', records: [],
end: state.definition.duration === undefined ? Infinity : parseDuration(state.definition.duration) };
this.active.set(owner, instance);
this.resolution.signals.set('signals.scenario.active', true);
this.resolution.invalidate();
state.status = 'STARTING';
this.trace('start', instance);
try {
this.actions.execute(state.definition.onStart ?? [], this.context(instance));
if (!this.active.has(owner)) return;
instance.records = this.sampleTimeline(state, instance.stream);
instance.state = state.status = 'ACTIVE';
} catch (error) { this.fail(instance, error); }
}
context(instance) { return { owner: instance.owner, origin: instance.owner, priority: instance.priority, domain: 'scenario', usage: 'scenario', stream: instance.stream }; }
fail(instance, error) {
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', objectId: instance.definitionId, owner: instance.owner, tick: this.resolution.tickIndex });
this.terminate(instance, 'FAILED');
}
evaluateTriggers() {
for (const state of this.definitions.values()) {
const t = state.trigger;
if (t.type === 'condition') {
const truth = this.resolution.evaluateCondition(t.when, state.stream);
if (!truth) { state.conditionState = 'armed'; state.holdAt = null; }
else if (state.conditionState !== 'disarmed') {
if (state.holdAt === null) state.holdAt = this.now;
state.conditionState = 'holding';
if (this.now - state.holdAt + SCENARIO_EPSILON >= parseDuration(t.for ?? '0ms')) {
state.conditionState = 'disarmed'; this.request(state.id);
}
}
} else if (state.next <= this.now + SCENARIO_EPSILON) {
if (t.type !== 'probability' || state.stream.nextFloat() < t.chance) this.request(state.id);
if (t.type === 'once') state.next = Infinity;
else state.next += t.type === 'random-interval' ? this.sampleTime({ random: t }, state.stream) : parseDuration(t.every);
// No catch-up burst for sub-tick authoring intervals.
if (state.next <= this.now) state.next = this.now + 1000 / 60;
}
}
}
dispatchPending() {
let started = false;
const queue = [...this.pending.values()].sort((a, b) => this.definitions.get(b.id).priority - this.definitions.get(a.id).priority || a.created - b.created || this.definitions.get(a.id).order - this.definitions.get(b.id).order);
for (const request of queue) {
if (!this.pending.has(request.id)) continue;
const state = this.definitions.get(request.id);
const policy = state.definition.concurrency?.policy ?? 'defer';
const conflicts = this.conflicts(state);
const allowed = this.eligible(state);
const canReplace = conflicts.length && policy === 'replace' && conflicts.every(i => state.priority > i.priority);
if (!allowed || (conflicts.length && !canReplace) || this.active.size - (canReplace ? conflicts.length : 0) >= SCENARIO_LIMITS.active) {
request.attempted = true;
if (policy !== 'defer') { this.pending.delete(request.id); state.status = 'DEFINED'; }
continue;
}
this.actions.consumeUnit({ owner: request.sourceOwner });
this.pending.delete(request.id);
if (canReplace) for (const instance of conflicts) this.terminate(instance, 'CANCELLED');
this.start(state); started = true;
}
return started;
}
nextRecord() {
const due = [];
for (const instance of this.active.values()) for (const record of instance.records) {
if (record.due <= instance.end + SCENARIO_EPSILON && instance.started + record.due <= this.now + SCENARIO_EPSILON) due.push({ instance, record });
}
due.sort((a, b) => (a.instance.started + a.record.due) - (b.instance.started + b.record.due) || a.instance.order - b.instance.order || a.record.index - b.record.index);
return due[0];
}
dispatchRecord(instance, record) {
this.actions.consumeUnit(this.context(instance)); // even an empty/skipped beat is bounded
const entry = record.entry;
let list = entry.actions ?? [];
if (entry.choose) {
const branches = entry.choose.filter(branch => branch.weight > 0 && (!branch.when || this.resolution.evaluateCondition(branch.when, instance.stream)));
// Normalize first, so finite large weights cannot overflow their sum.
const max = Math.max(0, ...branches.map(branch => branch.weight));
const sum = branches.reduce((n, branch) => n + branch.weight / max, 0);
let roll = instance.stream.nextFloat() * sum;
const branch = branches.find(branch => (roll -= branch.weight / max) < 0);
list = branch?.actions ?? [];
this.trace('branch', instance, { entry: record.index, branch: branch ? entry.choose.indexOf(branch) : null });
}
this.trace('beat', instance, { entry: record.index, beat: record.beat, due: record.due });
this.actions.execute(list, this.context(instance));
if (!this.active.has(instance.owner)) return;
record.beat += 1;
if (record.beat < (entry.repeat?.count ?? 1)) record.due += this.sampleTime(entry.repeat.every, instance.stream);
else instance.records.splice(instance.records.indexOf(record), 1);
}
update() {
if (this.disposed) return;
this.now = this.resolution.logicalMilliseconds;
this.resumeContinuous();
this.advanceCleanup();
for (const [id, request] of this.pending) if (request.expires <= this.now + SCENARIO_EPSILON) { this.pending.delete(id); this.definitions.get(id).status = 'DEFINED'; }
try {
this.evaluateTriggers();
// Work generated by an event/control joins this tick's bounded drain.
for (;;) {
const started = this.dispatchPending();
const next = this.nextRecord();
if (next) {
try { this.dispatchRecord(next.instance, next.record); }
catch (error) { this.fail(next.instance, error); if (error.code === 'ERR_DISPATCH_BUDGET') throw error; }
continue;
}
let completed = false;
for (const instance of [...this.active.values()]) if (this.now - instance.started + SCENARIO_EPSILON >= instance.end || (instance.end === Infinity && instance.records.length === 0)) {
this.terminate(instance, 'COMPLETED'); completed = true;
}
if (!started && !completed) break;
}
} catch (error) {
// Only owners implicated in this tick's discarded work fail.
const discarded = [...this.pending.values()].filter(r => !r.attempted);
const owners = new Set(discarded.map(r => r.sourceOwner));
for (const i of this.active.values()) if (i.records.some(r => i.started + r.due <= this.now + SCENARIO_EPSILON)) owners.add(i.owner);
for (const request of discarded) this.pending.delete(request.id);
for (const owner of owners) if (this.active.has(owner)) this.fail(this.active.get(owner), error);
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', tick: this.resolution.tickIndex });
}
this.resolution.signals.set('signals.scenario.active', this.active.size > 0);
this.resolution.invalidate();
}
terminate(instance, cause) {
if (!this.active.has(instance.owner)) return;
this.active.delete(instance.owner); // blocks reentrant dispatch before the hook
this.resolution.signals.set('signals.scenario.active', this.active.size > 0);
this.resolution.invalidate();
instance.records.length = 0;
const state = this.definitions.get(instance.definitionId);
instance.state = cause === 'COMPLETED' ? 'COMPLETING' : 'CANCELLING';
const cleanupOwner = `cleanup:${instance.owner}`;
const hook = cause === 'COMPLETED' ? state.definition.onComplete : state.definition.onCancel;
const context = { ...this.context(instance), owner: cleanupOwner, isTerminationHook: true, terminationUnits: 0 };
try {
for (const action of hook ?? []) {
try { this.actions.execute([action], context); }
catch (error) {
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', objectId: state.id });
if (context.terminationUnits > 256) break;
}
}
} finally {
const until = this.now + SCENARIO_LIMITS.cleanupMs;
const overrides = this.resolution.overrides;
for (const override of [...overrides.instances.values()]) if (override.owner === instance.owner) {
override.owner = cleanupOwner; override.releaseMs = Math.min(override.releaseMs, SCENARIO_LIMITS.cleanupMs);
overrides.beginRelease(override.id);
}
this.continuousRequests = this.continuousRequests.filter(r => r.options.owner !== instance.owner);
for (const voice of this.audioVoices()) if (voice.owner === instance.owner || voice.owner === cleanupOwner) {
voice.owner = cleanupOwner;
voice.releaseMs = Math.min(voice.releaseMs, SCENARIO_LIMITS.cleanupMs);
if (voice.mode === 'continuous') voice.stop();
// Logical cleanup also works with native audio paused or accelerated tests.
voice.cleanupAt = Math.min(until, this.now + (voice.mode === 'continuous' ? voice.releaseMs : (voice.endingBoundMs ?? SCENARIO_LIMITS.cleanupMs) + voice.releaseMs));
}
this.actions.visual?.cleanup(instance.owner);
this.cleanups.set(cleanupOwner, { owner: cleanupOwner, origin: instance.owner, until });
instance.state = state.lastState = cause;
state.status = cause; state.lastEnd = this.now;
this.trace('terminate', instance, { cause });
this.advanceCleanup();
}
}
advanceCleanup(force = false) {
for (const [id, cleanup] of this.cleanups) {
const expired = force || this.now + SCENARIO_EPSILON >= cleanup.until;
let remaining = 0, forced = false;
for (const voice of this.audioVoices()) if (voice.owner === id) {
if (expired || this.now + SCENARIO_EPSILON >= voice.cleanupAt) { voice.dispose(); forced ||= expired && voice.cleanupAt > this.now; }
else remaining++;
}
for (const override of [...this.resolution.overrides.instances.values()]) if (override.owner === id) {
if (expired) { this.resolution.overrides.instances.delete(override.id); forced = true; }
else remaining++;
}
const visual = this.actions.visual;
for (const v of [...(visual?.instances.values() ?? [])]) if (v.owner === cleanup.origin || (v.origin === cleanup.origin && v.cancelWithScenario)) {
if (expired) {
if (v.state !== 'FINISHED') v.transition('FINISHED');
v.transition('DISPOSED'); visual.instances.delete(v.id);
visual.systems = visual.systems.filter(s => s !== v.system); forced = true;
} else remaining++;
}
if (forced && !force) this.diagnostics?.warn?.('WARN_CLEANUP_FORCED', 'Scenario cleanup reached its five-second deadline.', { section: 'scenarios', owner: id });
if (!remaining || expired) this.cleanups.delete(id);
}
this.resolution.invalidate();
}
counters() {
const voices = this.audioVoices();
const visual = this.actions.visual;
return { scenarios: this.active.size, deferred: this.pending.size, cleanupOwners: this.cleanups.size,
schedulerRecords: [...this.active.values()].reduce((n, i) => n + i.records.length, 0) + this.pending.size + this.continuousRequests.length,
subscriptions: this.disposed ? 0 : [...this.definitions.values()].filter(s => s.trigger.type === 'event').length,
overrides: this.resolution.overrides.instances.size, audioVoices: voices.length,
audioNodes: voices.reduce((n, v) => n + (v.plan?.nodes?.length ?? 0), 0),
visualInstances: visual?.instances.size ?? 0, visualSystems: visual?.systems.length ?? 0 };
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.now = this.resolution.logicalMilliseconds;
this.pending.clear();
this.continuousRequests.length = 0;
for (const instance of [...this.active.values()]) this.terminate(instance, 'CANCELLED');
this.advanceCleanup(true);
this.resolution.signals.set('signals.scenario.active', false);
this.resolution.invalidate();
this.actions.scenarios = null;
}
}
+2
View File
@@ -1,5 +1,6 @@
import { validateAudioSubsystem } from './audio-graph.js';
import { validateCadenceSubsystem } from './cadence-validation.js';
import { validateScenarioSubsystem } from './scenario-validation.js';
import { matchVisualTarget } from './visual-contract.js';
import { validateVisualSubsystem } from './visual-validation.js';
import {
@@ -94,6 +95,7 @@ export function validateExhibit(document, filename = 'document.xzbt') {
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
validateVisualSubsystem(document, errors, { validateValueSpec, pushError });
validateCadenceSubsystem(document, errors, { validateValueSpec, validateCondition, pushError });
validateScenarioSubsystem(document, errors, { validateValueSpec, validateCondition, pushError });
return { valid: errors.length === 0, errors, warnings: [], filename };
}
+294
View File
@@ -0,0 +1,294 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { CommonGrammarPerformance } from '../src/runtime/performance.js';
import { VisualEngine } from '../src/runtime/visual-engine.js';
import { AudioSubsystem } from '../src/runtime/audio-engine.js';
import { validateExhibit } from '../src/runtime/validator.js';
import { ExhibitValidator } from '../tools/validate-exhibit.mjs';
import { buildScenarioAcceptance } from '../tools/build-scenario-acceptance.mjs';
const set = (value, target = 'state.count') => ({ type: 'set', target, value });
const increment = set({ op: 'add', args: [{ ref: 'state.count' }, 1] });
const scenario = (extra = {}) => ({ timeline: [], duration: '5s', ...extra });
const documentFor = scenarios => ({ xzbt: '0.1', meta: { id: 'scenario-test', name: 'Scenario test' }, runtime: { seed: 42 },
parameters: { level: { type: 'number', default: 0.2, min: 0, max: 1 } },
state: { count: { type: 'integer', initial: 0 }, flag: { type: 'boolean', initial: false } }, scenarios });
function setup(scenarios, extra = {}) {
const document = { ...documentFor(scenarios), ...extra };
const trace = [], diagnostics = [];
const p = new CommonGrammarPerformance({ id: document.meta.id, document }, 42, {
onScenarioTrace: e => trace.push(e), diagnostics: { error: (code, message) => diagnostics.push({ code, message }), warn: (code, message) => diagnostics.push({ code, message }) }
});
const ticks = count => p.advanceTicks(count);
const start = id => { p.scenarios.request(id); ticks(1); return [...p.scenarios.active.values()].find(i => i.definitionId === id); };
return { p, trace, diagnostics, ticks, start, document, count: () => p.engine.get('state.count') };
}
const codes = document => validateExhibit(document).errors.map(e => e.code);
test('21.6.1 duration boundary beats run before completion, empty finite timelines complete', () => {
const r = setup({ s: scenario({ timeline: [{ at: '5s', actions: [increment] }, { at: '5001ms', actions: [increment] }], onComplete: [increment] }) });
r.start('s'); r.ticks(299); assert.equal(r.count(), 0);
r.ticks(1); assert.equal(r.count(), 2); assert.equal(r.p.scenarios.active.size, 0);
assert.equal(r.p.scenarios.definitions.get('s').lastState, 'COMPLETED');
assert.equal(r.p.engine.get('signals.scenario.active'), false);
const m = setup({ s: scenario({ duration: '3m' }) }); m.start('s'); m.ticks(10800);
assert.equal(m.p.scenarios.definitions.get('s').lastState, 'COMPLETED');
});
test('21.6.2 absolute/forward-relative ties keep document order and repeats stay lazy', () => {
const r = setup({ s: { timeline: [
{ after: 'anchor', delay: '0ms', actions: [set(1)] },
{ id: 'anchor', at: '100ms', actions: [set(2)] },
{ after: 'anchor', delay: { random: { min: '100ms', max: '100ms' } }, repeat: { count: 3, every: '100ms' }, actions: [increment] }
] } });
r.start('s'); assert.equal(r.p.scenarios.counters().schedulerRecords, 3);
r.ticks(6); assert.equal(r.count(), 2);
r.ticks(18); assert.equal(r.count(), 5); assert.equal(r.p.scenarios.counters().schedulerRecords, 0);
assert.deepEqual(r.trace.filter(e => e.type === 'beat').map(e => e.due), [100, 100, 200, 300, 400]);
});
test('21.6.3 weighted choices exclude false/zero branches and preserve per-instance randomness', () => {
const defs = { s: { timeline: [{ at: '0ms', repeat: { count: 5, every: { random: { min: '20ms', max: '80ms' } } }, choose: [
{ weight: 1000, when: { op: 'eq', left: 1, right: 2 }, actions: [set(999)] },
{ weight: 0, actions: [set(999)] }, { weight: 1, actions: [increment] }, { weight: 3, actions: [increment] }
] }] } };
const a = setup(defs), b = setup(defs);
a.start('s'); b.start('s');
for (let i = 0; i < 60; i++) { a.p.execute([set(0, 'state.count')], { domain: 'manual-sample' }); a.ticks(1); b.ticks(1); }
assert.deepEqual(a.trace, b.trace); assert.equal(b.count(), 5);
});
test('21.6.4 once, interval, probability, random interval, event and manual opportunities', () => {
for (const trigger of [{ type: 'once', at: '50ms' }, { type: 'interval', every: '50ms' }, { type: 'random-interval', min: '50ms', max: '50ms' }, { type: 'probability', every: '50ms', chance: 1 }]) {
const r = setup({ s: { trigger, timeline: [], onStart: [increment] } }); r.ticks(7);
assert.equal(r.count(), trigger.type === 'once' ? 1 : 2, trigger.type);
}
const never = setup({ s: { trigger: { type: 'probability', every: '20ms', chance: 0 }, timeline: [], onStart: [increment] } }); never.ticks(30); assert.equal(never.count(), 0);
const r = setup({ s: scenario({ trigger: { type: 'event', event: 'go' }, onStart: [increment] }) }, { events: { go: { actions: [set(true, 'state.flag')] } } });
r.p.execute([{ type: 'event', event: 'go' }], { domain: 'manual-sample' }); r.ticks(1);
assert.equal(r.count(), 1); assert.equal(r.p.scenarios.counters().subscriptions, 1);
});
test('GC5 condition startup disarming, interrupted hold and false-tick rearming', () => {
const r = setup({ s: { timeline: [], trigger: { type: 'condition', when: { op: 'eq', left: { ref: 'state.flag' }, right: true }, for: '50ms' }, onStart: [increment] } });
r.p.engine.setState('state.flag', true); r.ticks(10); assert.equal(r.count(), 0);
r.p.engine.setState('state.flag', false); r.ticks(1);
r.p.engine.setState('state.flag', true); r.ticks(3); assert.equal(r.count(), 0);
r.p.engine.setState('state.flag', false); r.ticks(1);
r.p.engine.setState('state.flag', true); r.ticks(4); assert.equal(r.count(), 1);
r.ticks(50); assert.equal(r.count(), 1);
r.p.engine.setState('state.flag', false); r.ticks(1);
r.p.engine.setState('state.flag', true); r.ticks(4); assert.equal(r.count(), 2);
});
test('GC5 deferred deduplication preserves expiry and rechecks eligibility and cooldown', () => {
const r = setup({ s: { timeline: [], eligibility: { when: { op: 'eq', left: { ref: 'state.flag' }, right: true }, timeout: '100ms' }, cooldown: '100ms', onStart: [increment] } });
r.p.scenarios.request('s', { inputScope: { level: 1 } }); const original = r.p.scenarios.pending.get('s');
r.ticks(3); r.p.scenarios.request('s', { inputScope: { level: 2 } });
assert.equal(r.p.scenarios.pending.get('s'), original); assert.equal(original.triggerInputs.level, 1);
r.ticks(3); assert.equal(r.p.scenarios.pending.size, 0);
r.p.engine.setState('state.flag', true); r.start('s'); assert.equal(r.count(), 1);
r.p.scenarios.request('s'); r.ticks(5); assert.equal(r.count(), 1); r.ticks(1);
// Expiry is removed before the cooldown recheck at the same timestamp.
assert.equal(r.p.scenarios.pending.size, 0); assert.equal(r.count(), 1);
});
test('21.6.7 group/global exclusivity is symmetric; replacement is strict and deferred order stable', () => {
const r = setup({
a: scenario({ group: 'g', concurrency: { mode: 'parallel' } }),
b: scenario({ group: 'g', priority: 70, concurrency: { mode: 'exclusive', scope: 'group', policy: 'replace' } }),
c: scenario({ group: 'g', priority: 70, concurrency: { mode: 'exclusive', scope: 'group', policy: 'replace' } }),
d: scenario({ group: 'other' })
});
r.start('a'); r.start('d'); r.start('b');
assert.equal(r.p.scenarios.active.size, 2); assert.equal(r.p.scenarios.definitions.get('a').lastState, 'CANCELLED');
r.start('c'); assert.equal(r.p.scenarios.active.size, 2); assert.equal(r.p.scenarios.pending.size, 0);
r.p.scenarios.request('a'); r.ticks(1); assert.equal(r.p.scenarios.pending.size, 1);
r.p.scenarios.cancel('b'); r.ticks(1); assert.ok([...r.p.scenarios.active.values()].some(i => i.definitionId === 'a'));
const q = setup({ low: scenario({ priority: 10, concurrency: { mode: 'exclusive' } }), high: scenario({ priority: 90, concurrency: { mode: 'exclusive' } }) });
q.p.scenarios.request('low'); q.p.scenarios.request('high'); q.ticks(1);
assert.equal([...q.p.scenarios.active.values()][0].definitionId, 'high');
});
test('21.6.7 active capacity defers atomically and admits after cancellation', () => {
const r = setup(Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`s${i}`, scenario()])));
for (const id of r.p.scenarios.definitions.keys()) r.p.scenarios.request(id);
r.ticks(1); assert.equal(r.p.scenarios.active.size, 16); assert.equal(r.p.scenarios.pending.size, 1);
r.p.scenarios.cancel('s0'); r.ticks(1); assert.equal(r.p.scenarios.active.size, 16); assert.equal(r.p.scenarios.pending.size, 0);
});
function attachResources(r) {
const visual = new VisualEngine(r.document, { rng: r.p.rng, resolution: r.p.engine });
r.p.actions.visual = visual; r.p.onTick = step => visual.advance(step);
// Native API double only. Graph expansion, realization, SoundInstance lifecycle,
// voice pools and owner cleanup all execute production code.
const nodes = new Set();
const param = () => ({ value: 1, setValueAtTime() {}, linearRampToValueAtTime() {}, cancelScheduledValues() {} });
const node = () => { const n = { gain: param(), frequency: param(), detune: param(), connect(target) { return target; }, disconnect() { nodes.delete(n); }, start() {}, stop() {} }; nodes.add(n); return n; };
const context = { sampleRate: 48000, currentTime: 0, state: 'running', destination: node(), createGain: node, createOscillator: node, close: async () => {} };
const audio = new AudioSubsystem({ document: r.document, rng: r.p.rng, resolutionEngine: r.p.engine });
audio.context = context; audio.ready = true; audio.master = node(); audio.createVoiceGuard = () => null;
r.p.setAudio(audio);
return { visual, audio, nodes };
}
const resources = {
sounds: { drone: { name: 'Drone', usage: ['scenario'], recipe: { mode: 'continuous', release: '10s', nodes: { tone: { type: 'oscillator', frequency: 110 } }, routes: [{ from: 'tone', to: 'output' }] } } },
visuals: { scene: { coordinateSpace: 'virtual', width: 100, height: 100 }, systems: {
burst: { type: 'graphic', lifecycle: 'spawned', spawn: { release: '8s' }, content: { p: { type: 'point', position: { x: 50, y: 50 } } } }
} },
events: {
outer: { actions: [{ type: 'event', event: 'inner' }] },
inner: { actions: [{ type: 'sound', sound: 'drone', ownership: 'scenario' }, { type: 'spawn', target: 'visuals.systems.burst' }, { type: 'override', target: 'parameters.level', scope: 'duration', duration: '1h', value: 0.8, transition: { out: '20s' } }] }
}
};
test('GC5 nested ownership uses real voice pools, visual instances and duration overrides with bounded cleanup', async () => {
const r = setup({ s: scenario({ onStart: [{ type: 'event', event: 'outer' }] }) }, structuredClone(resources));
assert.deepEqual(validateExhibit(r.document).errors, []);
const { audio, visual } = attachResources(r);
const instance = r.start('s');
assert.equal(audio.continuousVoices.size, 1); assert.equal(visual.instances.size, 1);
assert.equal([...audio.voices][0].owner, instance.owner);
assert.equal([...visual.instances.values()][0].owner, instance.owner);
r.p.setParameter('level', 0.4);
r.p.scenarios.cancel('s');
assert.equal(audio.voices.size, 0); // released but still allocated in its pool
assert.equal(r.p.scenarios.counters().audioVoices, 1);
assert.equal(r.p.engine.overrides.instances.size, 1);
r.ticks(301);
assert.equal(audio.continuousVoices.size, 0); assert.equal(visual.instances.size, 0);
assert.equal(r.p.engine.overrides.instances.size, 0); assert.equal(r.p.engine.get('parameters.level'), 0.4);
assert.equal(r.p.scenarios.cleanups.size, 0); await audio.dispose();
});
test('GC5 critical startup/ordinary failures preserve set, run cancel hook, continue past hook failures', () => {
for (const phase of ['onStart', 'timeline']) {
const list = [set(7), { type: 'set', target: 'state.missing', value: 1 }, set(99)];
const r = setup({ s: scenario({ ...(phase === 'onStart' ? { onStart: list } : { timeline: [{ at: '0ms', actions: list }] }),
onCancel: [{ type: 'set', target: 'state.missing', value: 0 }, set(true, 'state.flag')] }) });
r.start('s'); assert.equal(r.count(), 7); assert.equal(r.p.engine.get('state.flag'), true);
assert.equal(r.p.scenarios.definitions.get('s').lastState, 'FAILED'); assert.equal(r.p.scenarios.active.size, 0);
}
});
test('GC5 dispatch exhaustion cannot be softened and reserves termination hook budget', () => {
const r = setup({ s: scenario({ onStart: Array.from({ length: 1030 }, () => ({ ...increment, critical: false })),
onCancel: [set(true, 'state.flag'), ...Array.from({ length: 260 }, () => increment)] }) });
r.start('s'); assert.equal(r.count(), 1023 + 255); assert.equal(r.p.engine.get('state.flag'), true);
assert.equal(r.p.scenarios.definitions.get('s').lastState, 'FAILED');
assert.ok(r.diagnostics.some(e => e.code === 'ERR_DISPATCH_BUDGET'));
});
test('GC5 repeated completion/cancellation/failure cycles return production counters and native allocations to baseline', async () => {
const r = setup({ s: scenario({ duration: '50ms', onStart: [{ type: 'event', event: 'outer' }], onCancel: [set(true, 'state.flag')] }) }, structuredClone(resources));
const { audio, nodes } = attachResources(r); const baseline = nodes.size;
for (let i = 0; i < 24; i++) {
r.start('s');
if (i % 3 === 0) r.p.scenarios.cancel('s');
else if (i % 3 === 1) r.p.scenarios.fail([...r.p.scenarios.active.values()][0], new Error('Injected failure'));
r.ticks(310);
const c = r.p.scenarios.counters();
for (const key of ['scenarios', 'deferred', 'schedulerRecords', 'overrides', 'audioVoices', 'audioNodes', 'visualInstances', 'cleanupOwners']) assert.equal(c[key], 0, `${i}: ${key}`);
assert.equal(nodes.size, baseline);
}
await audio.dispose();
});
test('21.6.14 pause reasons and direct disposal clear active ownership before resource teardown', async () => {
const r = setup({ s: scenario({ onStart: [{ type: 'event', event: 'outer' }], onCancel: [set(true, 'state.flag')] }) }, structuredClone(resources));
const { audio } = attachResources(r); r.start('s');
const time = r.p.engine.logicalMilliseconds;
r.p.pause(); r.p.pause('visibility'); r.p.resume('visibility'); r.ticks(100); assert.equal(r.p.engine.logicalMilliseconds, time);
r.p.resume(); r.ticks(1); assert.ok(r.p.engine.logicalMilliseconds > time);
await r.p.deactivate(); assert.equal(r.p.engine.get('state.flag'), true);
assert.equal(r.p.scenarios.counters().audioVoices, 0); assert.equal(r.p.scenarios.counters().visualInstances, 0);
assert.equal(r.p.scenarios.counters().subscriptions, 0); await audio.dispose();
});
test('continuous pre-unlock intentions disappear with their owner and never replay one-shots', () => {
const r = setup({ s: scenario({ onStart: [{ type: 'sound', sound: 'drone' }] }) }, structuredClone(resources));
r.start('s'); assert.equal(r.p.scenarios.continuousRequests.length, 1);
r.p.scenarios.cancel('s'); assert.equal(r.p.scenarios.continuousRequests.length, 0);
});
test('scenario control stop blocks remaining ordinary actions for the terminated owner', () => {
const r = setup({ s: scenario({ onStart: [{ type: 'control', target: 'scenarios.s', command: 'stop' }, set(99)], onCancel: [set(1)] }) });
r.start('s'); assert.equal(r.count(), 1); assert.equal(r.p.scenarios.active.size, 0);
});
test('scenario contract rejects malformed shapes, dangling anchors, feedback and unsupported hooks in both validators', () => {
const cases = [
[scenario({ priority: 101 }), 'ERR_OUT_OF_BOUNDS'], [scenario({ onFailure: [] }), 'ERR_UNKNOWN_FIELD'],
[scenario({ trigger: { type: 'interval', every: '0ms' } }), 'ERR_INVALID_DURATION'],
[scenario({ trigger: { type: 'random-interval', min: '2s', max: '1s' } }), 'ERR_INVALID_RANGE_ORDER'],
[scenario({ timeline: [{ after: 'missing', actions: [] }] }), 'ERR_INVALID_REFERENCE'],
[scenario({ timeline: [{ id: 'a', after: 'b', actions: [] }, { id: 'b', after: 'a', actions: [] }] }), 'ERR_CYCLIC_DEPENDENCY'],
[scenario({ timeline: [{ at: '0ms', actions: [], choose: [] }] }), 'ERR_SCHEMA_VALIDATION'],
[scenario({ onCancel: [{ type: 'event', event: 'outer' }] }), 'ERR_UNSUPPORTED_TARGET'],
[scenario({ onStart: [{ type: 'control', target: 'scenarios.s', command: 'start' }] }), 'ERR_CYCLIC_DEPENDENCY']
];
for (const [def, code] of cases) {
const document = { ...documentFor({ s: def }), ...structuredClone(resources) };
assert.ok(codes(document).includes(code), code);
assert.ok(new ExhibitValidator(document).validate().errors.some(e => e.code === code), `CLI: ${code}`);
}
const feedback = documentFor({ s: scenario({ trigger: { type: 'event', event: 'go' }, onStart: [{ type: 'event', event: 'indirect' }] }) });
feedback.events = { go: { actions: [increment] }, indirect: { actions: [{ type: 'event', event: 'go' }] } };
assert.ok(codes(feedback).includes('ERR_CYCLIC_DEPENDENCY'));
});
test('21.6.13 Exhibit E completes forty logical minutes through production fixed ticks reproducibly', () => {
const document = JSON.parse(readFileSync(new URL('../exhibits/exhibit-e.xzbt', import.meta.url)));
assert.deepEqual(validateExhibit(document).errors, []);
const run = () => {
const r = setup({}, document);
const visual = new VisualEngine(document, { rng: r.p.rng, resolution: r.p.engine });
r.p.actions.visual = visual;
// Rendering is intentionally absent; the same production simulation runs.
r.p.onTick = step => visual.advance(step);
r.start('journey'); r.ticks(40 * 60 * 60 + 301);
assert.equal(r.p.scenarios.definitions.get('journey').lastState, 'COMPLETED');
assert.equal(r.p.scenarios.counters().visualInstances, 0);
assert.equal(r.p.engine.get('state.mode'), 'normal');
assert.equal(r.p.scenarios.counters().overrides, 0);
assert.ok(r.p.engine.get('state.beats') > 0);
visual.dispose(); return r.trace;
};
assert.deepEqual(run(), run());
});
test('signals.scenario.active is visible to onStart and termination hooks in the same tick', () => {
const r = setup({ s: scenario({ onStart: [set({ ref: 'signals.scenario.active' }, 'state.flag')], onCancel: [set({ ref: 'signals.scenario.active' }, 'state.flag')] }) });
r.start('s'); assert.equal(r.p.engine.get('state.flag'), true);
r.p.scenarios.cancel('s'); assert.equal(r.p.engine.get('state.flag'), false);
});
test('a persistent sound and a detached persistent visual survive their originating scenario', async () => {
const extra = structuredClone(resources);
extra.visuals.systems.burst.spawn.ownership = 'persistent';
extra.visuals.systems.burst.spawn.cancelWithScenario = false;
const r = setup({ s: scenario({ onStart: [{ type: 'sound', sound: 'drone', ownership: 'persistent' }, { type: 'spawn', target: 'visuals.systems.burst', ownership: 'persistent' }] }) }, extra);
const { audio, visual } = attachResources(r); r.start('s'); r.p.scenarios.cancel('s'); r.ticks(301);
assert.equal(audio.continuousVoices.size, 1); assert.equal(visual.instances.size, 1);
assert.equal([...audio.continuousVoices][0].owner, 'performance');
await audio.dispose(); visual.dispose();
});
test('production audio releases obey paused logical time and dispose after the bounded release', async () => {
const r = setup({ s: scenario({ onStart: [{ type: 'sound', sound: 'drone' }] }) }, structuredClone(resources));
const { audio } = attachResources(r); r.start('s'); r.p.scenarios.cancel('s');
const voice = [...audio.continuousVoices][0]; assert.equal(voice.releaseTimer, null);
r.p.pause(); r.ticks(400); assert.equal(audio.continuousVoices.size, 1);
r.p.resume(); r.ticks(301); assert.equal(audio.continuousVoices.size, 0); await audio.dispose();
});
test('scenario acceptance build is reproducible and embeds only valid declarative fixtures', () => {
const a = buildScenarioAcceptance(), b = buildScenarioAcceptance();
assert.equal(a.html, b.html);
assert.ok(!/<script[^>]+src=/.test(a.html));
for (const fixture of a.fixtures) {
assert.deepEqual(validateExhibit(fixture).errors, []);
assert.deepEqual(new ExhibitValidator(fixture).validate().errors, []);
}
});
+44
View File
@@ -0,0 +1,44 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bundleRuntime } from './build-xzbt.mjs';
import { longScenarioExhibit, scenarioChallenge } from './scenario-fixtures.mjs';
import { validateExhibit } from '../src/runtime/validator.js';
export function buildScenarioAcceptance() {
const root = fileURLToPath(new URL('..', import.meta.url));
const workload = structuredClone(longScenarioExhibit);
workload.meta = { id: 'phase6-soak', name: 'Phase 6 development soak', version: '1.0.0' };
workload.runtime.seed = 4206;
workload.scenarios.pulse = { trigger: { type: 'interval', every: '20s' }, duration: '5s', onStart: [
{ type: 'event', event: 'signal' }, { type: 'sound', sound: 'drone' },
{ type: 'override', target: 'parameters.activity', value: 0.9, scope: 'scenario', transition: { out: '2s' } }
], timeline: [] };
const fixtures = [longScenarioExhibit, scenarioChallenge, workload];
mkdirSync(resolve(root, 'prototypes/phase6'), { recursive: true });
for (const document of fixtures) {
const result = validateExhibit(document);
if (!result.valid) throw new Error(JSON.stringify(result.errors));
writeFileSync(resolve(root, document === workload ? 'prototypes/phase6/workload-v1.xzbt' : `exhibits/${document.meta.id}.xzbt`), JSON.stringify(document, null, 2) + '\n');
}
const runtime = bundleRuntime(false);
const data = JSON.stringify(fixtures).replace(/</g, '\\u003c');
const identity = JSON.stringify({ workloadSha256: createHash('sha256').update(JSON.stringify(workload)).digest('hex'), runtimeSha256: createHash('sha256').update(runtime).digest('hex') });
const script = readFileSync(resolve(root, 'prototypes/phase6/acceptance.js'), 'utf8');
const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>XZBT Scenario Acceptance</title><style>
body{margin:0;background:#101d25;color:#e4efed;font:15px system-ui}main{max-width:1200px;margin:auto;padding:24px}h1{font-size:24px}button,select,textarea{font:inherit;background:#203b45;color:inherit;border:1px solid #6c9999;border-radius:5px;padding:9px;margin:4px}button:disabled{opacity:.45}canvas{width:100%;height:52vh;display:block;background:#101d25}#scenarios{display:flex;flex-wrap:wrap;gap:12px}article{border:1px solid #48636a;padding:12px}output,pre{font:13px ui-monospace;white-space:pre-wrap}textarea{display:block;width:95%;min-height:55px}label{display:block;margin-top:10px}.hint{color:#b5cbc7}
</style><main><h1>Scenario acceptance / XZBT 0.1</h1>
<p class="hint">Start or cancel a scenario, inspect its status, or run the frozen two-hour development workload. Acceleration verifies logical behavior; the soak requires real elapsed time and audio output.</p>
<select id="fixture" aria-label="Exhibit"></select><button id="restart">Restart</button><button id="audio">Start audio</button><button id="pause">Pause</button><button id="accelerate">Advance 40 logical minutes</button><button id="soak">Start two-hour soak</button><button id="export">Export evidence</button>
<canvas id="canvas" aria-label="Scenario visuals"></canvas><div id="scenarios"></div>
<button id="event">Invoke request event</button><button id="condition">Set condition true</button>
<p><output id="status" aria-live="polite"></output></p><pre id="counters"></pre>
<label>Reference computer: CPU, GPU/driver, RAM, OS, power mode<textarea id="environment"></textarea></label>
<label>Listening and display observations, including any clicks, clipping or glitches<textarea id="observations"></textarea></label>
<pre id="diagnostics"></pre></main><script>'use strict'; (() => {${runtime}\nconst scenarioFixtures = ${data}; const scenarioBuild = ${identity};\n${script}\n})();</script></html>`;
const path = resolve(root, 'prototypes/phase6/XZBT-scenario-acceptance.html');
writeFileSync(path, html); return { path, html, fixtures };
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) console.log(buildScenarioAcceptance().path);
+2
View File
@@ -27,6 +27,7 @@ const sourceFiles = Object.freeze([
'src/runtime/visual-systems.js',
'src/runtime/visual-validation.js',
'src/runtime/cadence-validation.js',
'src/runtime/scenario-validation.js',
'src/runtime/validator.js',
'src/runtime/persistence.js',
'src/runtime/library.js',
@@ -40,6 +41,7 @@ const sourceFiles = Object.freeze([
'src/runtime/visual-subsystem.js',
'src/runtime/actions.js',
'src/runtime/cadence.js',
'src/runtime/scenario.js',
'src/runtime/performance.js',
'src/runtime/activation.js',
'src/runtime/audio-engine.js',
+85
View File
@@ -0,0 +1,85 @@
const mutation = (target, value) => ({ type: 'set', target, value });
const addOne = target => mutation(target, { op: 'add', args: [{ ref: target }, 1] });
const override = (target, value, scope = 'scenario') => ({ type: 'override', target, value, scope, transition: { in: '1s', out: '2s', easing: 'ease-in-out' } });
export function scenarioAudio() {
return {
audio: { buses: { ambient: { gain: 0.5 }, effects: { gain: 0.5 } } },
sounds: {
bed: { name: 'Distant hum', bus: 'ambient', usage: ['automatic'], cadence: { class: 'ambient' }, recipe: {
mode: 'continuous', release: '1s', nodes: { tone: { type: 'oscillator', frequency: 55 }, trim: { type: 'gain', gain: 0.04 } }, routes: [{ from: 'tone', to: 'trim' }, { from: 'trim', to: 'output' }]
} },
drone: { name: 'Passing resonance', bus: 'ambient', usage: ['scenario'], recipe: {
mode: 'continuous', release: '2s', nodes: { tone: { type: 'oscillator', frequency: 165 }, trim: { type: 'gain', gain: 0.035 } }, routes: [{ from: 'tone', to: 'trim' }, { from: 'trim', to: 'output' }]
} },
beat: { name: 'Soft impulse', bus: 'effects', usage: ['automatic', 'scenario', 'manual'], cadence: { class: 'routine', overlap: false, cooldown: '3s' }, recipe: {
mode: 'oneshot', release: '50ms', nodes: { hit: { type: 'impulse', duration: '8ms', amplitude: 0.06 } }, routes: [{ from: 'hit', to: 'output' }]
} }
}
};
}
export function withTemporaryScenario(document) {
if (!['exhibit-a', 'exhibit-b'].includes(document.meta.id)) return document;
const result = structuredClone(document);
result.meta.description = 'Procedural audiovisual reference with cadence and a temporary scenario override; final acceptance remains Phase 9.';
result.parameters = { ...result.parameters, activity: { type: 'number', default: 0.4, min: 0, max: 1, label: 'Activity' } };
result.bindings = [...(result.bindings ?? []), { source: 'parameters.activity', target: 'visuals.camera.zoom', scale: 0.25, offset: 1 }];
result.cadence = { intensity: { ref: 'parameters.activity' } };
Object.assign(result, scenarioAudio());
result.scenarios = { disturbance: { name: 'Temporary disturbance', trigger: { type: 'random-interval', min: '30s', max: '90s' },
duration: '12s', cooldown: '20s', priority: 70, onStart: [override('parameters.activity', 0.95), { type: 'sound', sound: 'drone' }], timeline: [] } };
return result;
}
export const longScenarioExhibit = {
xzbt: '0.1', meta: { id: 'exhibit-e', name: 'Exhibit E — Long Passage', version: '0.1.0', description: 'A forty-minute procedural passage: approach, repeated signals, branching disturbance, and recovery.' },
runtime: { seed: 42 }, parameters: { activity: { type: 'number', default: 0.35, min: 0, max: 1, label: 'Activity' } },
state: { mode: { type: 'string', initial: 'normal' }, beats: { type: 'integer', initial: 0 }, journeys: { type: 'integer', initial: 0 } },
...scenarioAudio(), cadence: { intensity: { ref: 'parameters.activity' }, minGap: '1500ms' },
bindings: [{ source: 'parameters.activity', target: 'visuals.camera.zoom', scale: 0.2, offset: 1 }],
visuals: { scene: { coordinateSpace: 'virtual', width: 960, height: 540, background: '#101d25' }, systems: {
horizon: { type: 'graphic', content: {
ring: { type: 'ring', radius: 150, innerRadius: 148, position: { x: 480, y: 270 }, style: { fill: '#80beb5' } },
line: { type: 'line', position: { x: 240, y: 270 }, to: { x: 480, y: 0 }, style: { stroke: '#dfc18d', strokeWidth: 2 } }
} },
signal: { type: 'graphic', lifecycle: 'spawned', spawn: { lifetime: '4s', release: '1s' }, content: {
ring: { type: 'ring', radius: 165, innerRadius: 160, position: { x: 480, y: 270 }, style: { fill: '#efbe79' }, behaviors: [{ type: 'rotate', speed: 12 }] }
} }
} },
events: {
signal: { actions: [addOne('state.beats'), { type: 'sound', sound: 'beat' }, { type: 'spawn', target: 'visuals.systems.signal' }] },
recover: { actions: [mutation('state.mode', 'normal')] }
},
scenarios: {
journey: { name: 'Long passage', priority: 70, group: 'passage', trigger: { type: 'random-interval', min: '45m', max: '60m' },
concurrency: { mode: 'exclusive', scope: 'group', policy: 'defer' }, duration: '40m', cooldown: '5m',
onStart: [addOne('state.journeys'), mutation('state.mode', 'approach'), { type: 'sound', sound: 'drone' }, override('parameters.activity', 0.7)],
timeline: [
{ id: 'arrival', at: '1m', actions: [{ type: 'event', event: 'signal' }] },
{ after: 'arrival', delay: { random: { min: '20s', max: '50s' } }, repeat: { count: 12, every: { random: { min: '1m', max: '2m' } } }, actions: [{ type: 'event', event: 'signal' }] },
{ at: '15m', choose: [
{ weight: 3, actions: [mutation('state.mode', 'drift'), { ...override('parameters.activity', 0.5, 'duration'), duration: '5m' }] },
{ weight: 1, actions: [mutation('state.mode', 'disturbance'), { ...override('parameters.activity', 0.95, 'duration'), duration: '5m' }, { type: 'event', event: 'signal' }] }
] },
{ at: '30m', actions: [{ type: 'event', event: 'recover' }] },
{ at: '40m', actions: [mutation('state.mode', 'normal')] }
], onComplete: [mutation('state.mode', 'normal')], onCancel: [mutation('state.mode', 'normal')]
}
}
};
export const scenarioChallenge = {
...structuredClone(longScenarioExhibit),
meta: { id: 'scenario-challenge', name: 'Scenario Challenge', version: '1.0.0', description: 'Short, multi-minute, event, condition and concurrent scenario acceptance cases.' },
state: { ...longScenarioExhibit.state, armed: { type: 'boolean', initial: false } },
events: { ...longScenarioExhibit.events, request: { actions: [mutation('state.mode', 'requested')] } },
scenarios: {
brief: { name: 'Five seconds', duration: '5s', timeline: [{ at: '0ms', actions: [{ type: 'event', event: 'signal' }] }], onStart: [override('parameters.activity', 0.9)] },
minutes: { name: 'Three minutes', duration: '3m', timeline: [{ at: '10s', repeat: { count: 8, every: '20s' }, actions: [{ type: 'event', event: 'signal' }] }] },
condition: { name: 'Condition hold', trigger: { type: 'condition', when: { op: 'eq', left: { ref: 'state.armed' }, right: true }, for: '2s' }, duration: '5s', timeline: [], onStart: [override('parameters.activity', 0.6)] },
event: { name: 'Event trigger', trigger: { type: 'event', event: 'request' }, duration: '5s', timeline: [], onStart: [{ type: 'event', event: 'signal' }] },
exclusive: { name: 'Exclusive group', priority: 80, group: 'g', concurrency: { mode: 'exclusive', scope: 'group' }, duration: '10s', timeline: [], onStart: [{ type: 'sound', sound: 'drone' }] },
deferred: { name: 'Deferred group', priority: 50, group: 'g', concurrency: { mode: 'exclusive', scope: 'group', policy: 'defer' }, duration: '5s', timeline: [], onStart: [{ type: 'event', event: 'signal' }] }
}
};
+5
View File
@@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { validateAudioSubsystem } from '../src/runtime/audio-graph.js';
import { validateCadenceSubsystem } from '../src/runtime/cadence-validation.js';
import { validateScenarioSubsystem } from '../src/runtime/scenario-validation.js';
import { matchVisualTarget } from '../src/runtime/visual-contract.js';
import { validateVisualSubsystem } from '../src/runtime/visual-validation.js';
@@ -175,6 +176,10 @@ export class ExhibitValidator {
pushError: (errors, code, path, message) => errors.push({ code, path, message })
});
validateScenarioSubsystem(this.doc, this.errors, {
validateValueSpec: (unusedDocument, spec, path, unusedErrors, scope) => this.validateValueSpec(spec, path, scope),
validateCondition: (unusedDocument, cond, path) => this.validateConditionSpec(cond, path)
});
return this.getResult();
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Authored exhibit data only. Subject names occur here, never in renderer dispatch.
import { withTemporaryScenario } from './scenario-fixtures.mjs';
const random = (min, max) => ({ random: { min, max } });
const dot = (color = '#cfe8ff', size = 3) => ({ type: 'point', style: { fill: color, pointSize: size } });
const lineStyle = { stroke: '#82b7c7', strokeWidth: 2 };
@@ -87,5 +88,5 @@ export const visualExhibits = [
document.state = { level: { type: 'number', initial: 0.7, min: 0, max: 1 } };
document.bindings = [{ source: 'state.level', target: 'visuals.layers.display.opacity', scale: 0.7, offset: 0.3 }];
}
return { id, document };
return { id, document: withTemporaryScenario(document) };
});