From 1cde2f9f680348be570c76a501378e861066d299 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Sun, 6 Sep 2026 22:54:31 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA --- XZBT.html | 681 +- docs/XZBT_0-1_Format_Specification.md | 202 +- exhibits/exhibit-a.xzbt | 163 +- exhibits/exhibit-b.xzbt | 163 +- exhibits/exhibit-e.xzbt | 439 + exhibits/scenario-challenge.xzbt | 404 + package.json | 4 +- prototypes/phase4/XZBT-visual-acceptance.html | 632 +- prototypes/phase6/README.md | 34 + .../phase6/XZBT-scenario-acceptance.html | 10629 ++++++++++++++++ prototypes/phase6/acceptance.js | 122 + prototypes/phase6/workload-v1.xzbt | 465 + schema/xzbt-0.1.schema.json | 550 +- src/XZBT.template.html | 4 +- src/runtime/actions.js | 18 +- src/runtime/app.js | 47 +- src/runtime/audio-engine.js | 19 +- src/runtime/constants.js | 2 +- src/runtime/performance.js | 38 +- src/runtime/resolution.js | 5 +- src/runtime/scenario-validation.js | 201 + src/runtime/scenario.js | 350 + src/runtime/validator.js | 2 + test/phase6-scenarios.test.mjs | 294 + tools/build-scenario-acceptance.mjs | 44 + tools/build-xzbt.mjs | 2 + tools/scenario-fixtures.mjs | 85 + tools/validate-exhibit.mjs | 5 + tools/visual-challenge-fixtures.mjs | 3 +- 29 files changed, 15553 insertions(+), 54 deletions(-) create mode 100644 exhibits/exhibit-e.xzbt create mode 100644 exhibits/scenario-challenge.xzbt create mode 100644 prototypes/phase6/README.md create mode 100644 prototypes/phase6/XZBT-scenario-acceptance.html create mode 100644 prototypes/phase6/acceptance.js create mode 100644 prototypes/phase6/workload-v1.xzbt create mode 100644 src/runtime/scenario-validation.js create mode 100644 src/runtime/scenario.js create mode 100644 test/phase6-scenarios.test.mjs create mode 100644 tools/build-scenario-acceptance.mjs create mode 100644 tools/scenario-fixtures.mjs diff --git a/XZBT.html b/XZBT.html index b8478ae..5812f9d 100644 --- a/XZBT.html +++ b/XZBT.html @@ -151,7 +151,7 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh Active exhibit

Exhibit ID
Resolved seed
Deterministic visual-stream preview
-

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.

+

Declarative sound, visuals, cadence, and scenarios share one logical clock. Parameter edits remain stored while a temporary scenario override is active.

Parameters

Audio

@@ -166,6 +166,8 @@ main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-heigh

Resolved values

    Generated condition: n/a

    +

    Scenarios

    + @@ -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(); diff --git a/docs/XZBT_0-1_Format_Specification.md b/docs/XZBT_0-1_Format_Specification.md index 59a7561..51505e5 100644 --- a/docs/XZBT_0-1_Format_Specification.md +++ b/docs/XZBT_0-1_Format_Specification.md @@ -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', ':select:')` 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 92–102. 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--`, 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 / :trigger:1`. Each admitted instance +uses `scenario / :` 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."` 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` (1–1024, 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. diff --git a/exhibits/exhibit-a.xzbt b/exhibits/exhibit-a.xzbt index b4fb9d9..4537dd6 100644 --- a/exhibits/exhibit-a.xzbt +++ b/exhibits/exhibit-a.xzbt @@ -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": [] + } } } diff --git a/exhibits/exhibit-b.xzbt b/exhibits/exhibit-b.xzbt index 6fc9330..8f42d49 100644 --- a/exhibits/exhibit-b.xzbt +++ b/exhibits/exhibit-b.xzbt @@ -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": [] + } } } diff --git a/exhibits/exhibit-e.xzbt b/exhibits/exhibit-e.xzbt new file mode 100644 index 0000000..abea448 --- /dev/null +++ b/exhibits/exhibit-e.xzbt @@ -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" + } + ] + } + } +} diff --git a/exhibits/scenario-challenge.xzbt b/exhibits/scenario-challenge.xzbt new file mode 100644 index 0000000..bcacfe5 --- /dev/null +++ b/exhibits/scenario-challenge.xzbt @@ -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" + } + ] + } + } +} diff --git a/package.json b/package.json index 3c9fad3..51c6528 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/prototypes/phase4/XZBT-visual-acceptance.html b/prototypes/phase4/XZBT-visual-acceptance.html index bef9997..bc294ce 100644 --- a/prototypes/phase4/XZBT-visual-acceptance.html +++ b/prototypes/phase4/XZBT-visual-acceptance.html @@ -6,7 +6,7 @@ body{margin:0;background:#101820;color:#e5eee9;font:15px system-ui}header{paddin \ No newline at end of file diff --git a/prototypes/phase6/acceptance.js b/prototypes/phase6/acceptance.js new file mode 100644 index 0000000..1b88c33 --- /dev/null +++ b/prototypes/phase6/acceptance.js @@ -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(); diff --git a/prototypes/phase6/workload-v1.xzbt b/prototypes/phase6/workload-v1.xzbt new file mode 100644 index 0000000..a1ca501 --- /dev/null +++ b/prototypes/phase6/workload-v1.xzbt @@ -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": [] + } + } +} diff --git a/schema/xzbt-0.1.schema.json b/schema/xzbt-0.1.schema.json index 5527e5e..b03ccd6 100644 --- a/schema/xzbt-0.1.schema.json +++ b/schema/xzbt-0.1.schema.json @@ -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" + ] } } } diff --git a/src/XZBT.template.html b/src/XZBT.template.html index 3660b72..f2d5197 100644 --- a/src/XZBT.template.html +++ b/src/XZBT.template.html @@ -29,7 +29,7 @@ Active exhibit

    Exhibit ID
    Resolved seed
    Deterministic visual-stream preview
    -

    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.

    +

    Declarative sound, visuals, cadence, and scenarios share one logical clock. Parameter edits remain stored while a temporary scenario override is active.

    Parameters

    Audio

    @@ -44,6 +44,8 @@

    Resolved values

      Generated condition: n/a

      +

      Scenarios

      + diff --git a/src/runtime/actions.js b/src/runtime/actions.js index 24b1596..3167bea 100644 --- a/src/runtime/actions.js +++ b/src/runtime/actions.js @@ -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`); diff --git a/src/runtime/app.js b/src/runtime/app.js index 4483aa0..0ae91e6 100644 --- a/src/runtime/app.js +++ b/src/runtime/app.js @@ -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(); diff --git a/src/runtime/audio-engine.js b/src/runtime/audio-engine.js index d7765b5..b12645d 100644 --- a/src/runtime/audio-engine.js +++ b/src/runtime/audio-engine.js @@ -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; diff --git a/src/runtime/constants.js b/src/runtime/constants.js index 04dcc80..ea77e9a 100644 --- a/src/runtime/constants.js +++ b/src/runtime/constants.js @@ -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', diff --git a/src/runtime/performance.js b/src/runtime/performance.js index a605803..6f69a7d 100644 --- a/src/runtime/performance.js +++ b/src/runtime/performance.js @@ -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') { diff --git a/src/runtime/resolution.js b/src/runtime/resolution.js index fb00636..b6ee4d9 100644 --- a/src/runtime/resolution.js +++ b/src/runtime/resolution.js @@ -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() { diff --git a/src/runtime/scenario-validation.js b/src/runtime/scenario-validation.js new file mode 100644 index 0000000..52ba432 --- /dev/null +++ b/src/runtime/scenario-validation.js @@ -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); +} diff --git a/src/runtime/scenario.js b/src/runtime/scenario.js new file mode 100644 index 0000000..8ec8403 --- /dev/null +++ b/src/runtime/scenario.js @@ -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; + } +} diff --git a/src/runtime/validator.js b/src/runtime/validator.js index 51b2cdd..757bd1e 100644 --- a/src/runtime/validator.js +++ b/src/runtime/validator.js @@ -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 }; } diff --git a/test/phase6-scenarios.test.mjs b/test/phase6-scenarios.test.mjs new file mode 100644 index 0000000..a371b02 --- /dev/null +++ b/test/phase6-scenarios.test.mjs @@ -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(!/]+src=/.test(a.html)); + for (const fixture of a.fixtures) { + assert.deepEqual(validateExhibit(fixture).errors, []); + assert.deepEqual(new ExhibitValidator(fixture).validate().errors, []); + } +}); diff --git a/tools/build-scenario-acceptance.mjs b/tools/build-scenario-acceptance.mjs new file mode 100644 index 0000000..f2389b3 --- /dev/null +++ b/tools/build-scenario-acceptance.mjs @@ -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(/ +XZBT Scenario Acceptance

      Scenario acceptance / XZBT 0.1

      +

      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.

      + +
      + +

      
      +
      +
      +
      `; + 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); diff --git a/tools/build-xzbt.mjs b/tools/build-xzbt.mjs index d1253c0..4668275 100644 --- a/tools/build-xzbt.mjs +++ b/tools/build-xzbt.mjs @@ -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', diff --git a/tools/scenario-fixtures.mjs b/tools/scenario-fixtures.mjs new file mode 100644 index 0000000..084ded0 --- /dev/null +++ b/tools/scenario-fixtures.mjs @@ -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' }] } + } +}; diff --git a/tools/validate-exhibit.mjs b/tools/validate-exhibit.mjs index 1806705..a6f2ce0 100644 --- a/tools/validate-exhibit.mjs +++ b/tools/validate-exhibit.mjs @@ -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(); } diff --git a/tools/visual-challenge-fixtures.mjs b/tools/visual-challenge-fixtures.mjs index 7b8df97..4610063 100644 --- a/tools/visual-challenge-fixtures.mjs +++ b/tools/visual-challenge-fixtures.mjs @@ -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) }; });