Files
XZBT/tools/scenario-fixtures.mjs
LabyricornandClaude Opus 5 1cde2f9f68 feat(scenario): implement the phase 6 scenario director
Add Format Specification section 21 (Scenario Model 0.1) and the runtime
that realizes it: the scenario director, seven trigger classes, timelines
with repeats and branches, admission and concurrency control, nested
ownership with bounded cleanup, and the production GC5 resource counters.

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
2026-09-06 22:54:31 +00:00

86 lines
6.8 KiB
JavaScript

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' }] }
}
};