feat(scenario): implement the phase 6 scenario director
Add Format Specification section 21 (Scenario Model 0.1) and the runtime that realizes it: the scenario director, seven trigger classes, timelines with repeats and branches, admission and concurrency control, nested ownership with bounded cleanup, and the production GC5 resource counters. Exhibit E provides a reproducible forty-minute long-scenario reference and scenario-challenge.xzbt covers the PRD 131 cases. A standalone acceptance/soak page is built by tools/build-scenario-acceptance.mjs. 252 automated checks pass. The two-hour real-duration development soak required by the GC6 schedule remains pending. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bundleRuntime } from './build-xzbt.mjs';
|
||||
import { longScenarioExhibit, scenarioChallenge } from './scenario-fixtures.mjs';
|
||||
import { validateExhibit } from '../src/runtime/validator.js';
|
||||
|
||||
export function buildScenarioAcceptance() {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const workload = structuredClone(longScenarioExhibit);
|
||||
workload.meta = { id: 'phase6-soak', name: 'Phase 6 development soak', version: '1.0.0' };
|
||||
workload.runtime.seed = 4206;
|
||||
workload.scenarios.pulse = { trigger: { type: 'interval', every: '20s' }, duration: '5s', onStart: [
|
||||
{ type: 'event', event: 'signal' }, { type: 'sound', sound: 'drone' },
|
||||
{ type: 'override', target: 'parameters.activity', value: 0.9, scope: 'scenario', transition: { out: '2s' } }
|
||||
], timeline: [] };
|
||||
const fixtures = [longScenarioExhibit, scenarioChallenge, workload];
|
||||
mkdirSync(resolve(root, 'prototypes/phase6'), { recursive: true });
|
||||
for (const document of fixtures) {
|
||||
const result = validateExhibit(document);
|
||||
if (!result.valid) throw new Error(JSON.stringify(result.errors));
|
||||
writeFileSync(resolve(root, document === workload ? 'prototypes/phase6/workload-v1.xzbt' : `exhibits/${document.meta.id}.xzbt`), JSON.stringify(document, null, 2) + '\n');
|
||||
}
|
||||
const runtime = bundleRuntime(false);
|
||||
const data = JSON.stringify(fixtures).replace(/</g, '\\u003c');
|
||||
const identity = JSON.stringify({ workloadSha256: createHash('sha256').update(JSON.stringify(workload)).digest('hex'), runtimeSha256: createHash('sha256').update(runtime).digest('hex') });
|
||||
const script = readFileSync(resolve(root, 'prototypes/phase6/acceptance.js'), 'utf8');
|
||||
const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>XZBT Scenario Acceptance</title><style>
|
||||
body{margin:0;background:#101d25;color:#e4efed;font:15px system-ui}main{max-width:1200px;margin:auto;padding:24px}h1{font-size:24px}button,select,textarea{font:inherit;background:#203b45;color:inherit;border:1px solid #6c9999;border-radius:5px;padding:9px;margin:4px}button:disabled{opacity:.45}canvas{width:100%;height:52vh;display:block;background:#101d25}#scenarios{display:flex;flex-wrap:wrap;gap:12px}article{border:1px solid #48636a;padding:12px}output,pre{font:13px ui-monospace;white-space:pre-wrap}textarea{display:block;width:95%;min-height:55px}label{display:block;margin-top:10px}.hint{color:#b5cbc7}
|
||||
</style><main><h1>Scenario acceptance / XZBT 0.1</h1>
|
||||
<p class="hint">Start or cancel a scenario, inspect its status, or run the frozen two-hour development workload. Acceleration verifies logical behavior; the soak requires real elapsed time and audio output.</p>
|
||||
<select id="fixture" aria-label="Exhibit"></select><button id="restart">Restart</button><button id="audio">Start audio</button><button id="pause">Pause</button><button id="accelerate">Advance 40 logical minutes</button><button id="soak">Start two-hour soak</button><button id="export">Export evidence</button>
|
||||
<canvas id="canvas" aria-label="Scenario visuals"></canvas><div id="scenarios"></div>
|
||||
<button id="event">Invoke request event</button><button id="condition">Set condition true</button>
|
||||
<p><output id="status" aria-live="polite"></output></p><pre id="counters"></pre>
|
||||
<label>Reference computer: CPU, GPU/driver, RAM, OS, power mode<textarea id="environment"></textarea></label>
|
||||
<label>Listening and display observations, including any clicks, clipping or glitches<textarea id="observations"></textarea></label>
|
||||
<pre id="diagnostics"></pre></main><script>'use strict'; (() => {${runtime}\nconst scenarioFixtures = ${data}; const scenarioBuild = ${identity};\n${script}\n})();</script></html>`;
|
||||
const path = resolve(root, 'prototypes/phase6/XZBT-scenario-acceptance.html');
|
||||
writeFileSync(path, html); return { path, html, fixtures };
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) console.log(buildScenarioAcceptance().path);
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }] }
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user