feat(cadence): implement the phase 5 event and cadence subsystems

Section 20 in full: the automatic scheduling algorithm with its eligible-pool
filtering, cooldown and overlap exclusion, weight evaluation, the anti-repetition
multiplier table applied by recency-queue position, pool relaxation when total
effective weight reaches zero, and reschedule by uniform sample divided by
intensity; the priority clocks and minimum-gap servicing of 20.6 with deferred
classes retained; intensity clamping and the pause threshold of 20.7, under
which ambient voices keep running; the 1024-unit dispatch budget and sixteen
levels of event nesting; and manual SAMPLE evaluation in an isolated stream that
does not perturb cadence scheduling.

Two defects found by the section 20 review triage are fixed here rather than
shipped. A sound action's usage check tested whether the target sound permitted
*some* action context instead of the caller's own, so a scenario-only sound was
playable from a manual action; it now checks the caller's context and rejects
with ERR_UNSUPPORTED_TARGET, the code 20.2.2 and 20.12 actually name, and the
two cases the previous test never exercised — manual-only from scenario, and
scenario-only from manual — are covered.

Traces 7 through 10 drove `triggerSound()` and `calculateEligiblePool()`, a
duplicate scheduler with no production call site, rather than the
`advance()`/`fireClass()` path a real exhibit runs. The two implementations
disagreed on recency-history semantics: 20.5.9 requires a plain queue of the
last four firings, and the duplicate moved an existing entry to the front
instead. Nothing caught it because nothing called both. Those traces now drive
`update()`/`advance()` with a seeded RNG, as traces 11 through 14 already did,
and the duplicate is deleted rather than reconciled.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
This commit is contained in:
2026-09-06 21:53:51 +00:00
co-authored by Claude Opus 5
parent d5d7091289
commit 3db1df058f
7 changed files with 1788 additions and 11 deletions
+152 -5
View File
@@ -1,18 +1,95 @@
import { RuntimeFault, isRecord } from './types.js';
import { RuntimeFault, isRecord, valueMatchesType } from './types.js';
import { ValueResolver, ConditionEvaluator } from './values.js';
export class ActionExecutor {
constructor(engine, { diagnostics } = {}) {
this.engine = engine;
this.diagnostics = diagnostics;
this.invocationOrdinal = 0;
this.visual = null;
this.audio = null;
this.dispatchUnits = 0;
}
resetBudget() {
this.dispatchUnits = 0;
}
consumeUnit(context = {}) {
if (context.isTerminationHook) {
if ((context.terminationUnits = (context.terminationUnits ?? 0) + 1) > 256) {
throw new RuntimeFault('ERR_DISPATCH_BUDGET', 'Termination hook action limit (256) exceeded.');
}
} else {
if (this.dispatchUnits >= 1024) {
throw new RuntimeFault('ERR_DISPATCH_BUDGET', 'Ordinary event/action work exceeded the per-tick dispatch budget (1024).');
}
this.dispatchUnits += 1;
}
}
executeAction(action, context = {}) {
try {
const res = this.execute([action], context);
return res[0]?.status === 'executed';
} catch (error) {
const fault = error instanceof RuntimeFault ? error : new RuntimeFault('ERR_ACTION_FAILURE', error.message);
this.diagnostics?.error?.(fault.code, fault.message);
return false;
}
}
get tickActionCount() {
return this.dispatchUnits;
}
resetTickBudget() {
this.resetBudget();
}
evaluateValue(spec, stream, path, inputScope = null) {
if (inputScope) {
const resolver = new ValueResolver((ref) => {
if (typeof ref === 'string' && ref.startsWith('inputs.')) {
const inputName = ref.slice('inputs.'.length);
if (inputScope && Object.hasOwn(inputScope, inputName)) {
return inputScope[inputName];
}
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Event input '${inputName}' is not available here.`, path);
}
return this.engine.get(ref);
});
return resolver.evaluate(spec, stream, path);
}
return this.engine.evaluateValue(spec, stream, path);
}
evaluateCondition(spec, stream, path, inputScope = null) {
if (inputScope) {
const resolver = new ValueResolver((ref) => {
if (typeof ref === 'string' && ref.startsWith('inputs.')) {
const inputName = ref.slice('inputs.'.length);
if (inputScope && Object.hasOwn(inputScope, inputName)) {
return inputScope[inputName];
}
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Event input '${inputName}' is not available here.`, path);
}
return this.engine.get(ref);
});
const evaluator = new ConditionEvaluator(resolver);
return evaluator.evaluate(spec, stream, path);
}
return this.engine.evaluateCondition(spec, stream, path);
}
execute(actions, context = {}) {
if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.');
const stream = this.engine.rng.stream('scenario', `actions:${++this.invocationOrdinal}`);
const domain = context.domain ?? 'scenario';
const stream = this.engine.rng.stream(domain, `actions:${++this.invocationOrdinal}`);
const results = [];
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
this.consumeUnit(context);
try {
results.push(this.executeOne(action, stream, context, `$.actions[${index}]`));
} catch (error) {
@@ -27,23 +104,93 @@ 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 (action.when !== undefined && !this.engine.evaluateCondition(action.when, stream, `${path}.when`)) return { status: 'skipped', reason: 'condition' };
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 === 'set') {
const value = this.engine.evaluateValue(action.value, stream, `${path}.value`);
const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope);
this.engine.setState(action.target, value, action.transition);
return { status: 'executed', type: 'set', target: action.target, value };
}
if (action.type === 'override') {
const value = this.engine.evaluateValue(action.value, stream, `${path}.value`);
const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope);
const instanceId = this.engine.overrides.add(action, value, { owner: context.owner, inheritedPriority: context.priority });
this.engine.resolveAll();
return { status: 'executed', type: 'override', target: action.target, value, instanceId };
}
if (action.type === 'spawn' && this.visual) {
const inputs = Object.fromEntries(Object.entries(action.with ?? {}).map(([key, value]) => [key, this.evaluateValue(value, stream, `${path}.with.${key}`, context.inputScope)]));
const lifetime = action.lifetime === undefined ? undefined : this.evaluateValue(action.lifetime, stream, `${path}.lifetime`, context.inputScope);
const instance = this.visual.spawn(action.target?.replace(/^visuals\.systems\./, ''), { ...context, inputs, lifetime, ownership: action.ownership });
return { status: instance ? 'executed' : 'refused', type: 'spawn', instanceId: instance?.id ?? null };
}
if (action.type === 'remove' && this.visual) {
// C20: Return refused/failed when the instance ID is unknown.
const removed = this.visual.remove(action.target);
return { status: removed ? 'executed' : 'refused', type: 'remove', target: action.target };
}
if (action.type === 'event') {
this.consumeUnit(context);
const depth = (context.depth ?? 0) + 1;
if (depth > 16) {
throw new RuntimeFault('ERR_DISPATCH_BUDGET', `Event nesting depth (${depth}) exceeded maximum allowed depth (16).`, `${path}.event`);
}
const eventDef = this.engine.document.events?.[action.event];
if (!eventDef) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Referenced event '${action.event}' does not exist.`, `${path}.event`);
}
const inputScope = {};
for (const [inputId, inputSpec] of Object.entries(eventDef.inputs ?? {})) {
if (inputSpec.default !== undefined) {
inputScope[inputId] = inputSpec.default;
}
}
for (const [inputId, inputValue] of Object.entries(action.with ?? {})) {
const inputSpec = eventDef.inputs?.[inputId];
if (!inputSpec) {
throw new RuntimeFault('ERR_UNKNOWN_FIELD', `Unknown input '${inputId}' for event '${action.event}'.`, `${path}.with.${inputId}`);
}
const evaluated = this.evaluateValue(inputValue, stream, `${path}.with.${inputId}`, context.inputScope);
if (!valueMatchesType(inputSpec.type, evaluated, inputSpec)) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Input '${inputId}' value does not match declared type '${inputSpec.type}'.`, `${path}.with.${inputId}`);
}
inputScope[inputId] = evaluated;
}
for (const [inputId, inputSpec] of Object.entries(eventDef.inputs ?? {})) {
if (!Object.hasOwn(inputScope, inputId) && inputSpec.default === undefined) {
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Required input '${inputId}' not provided for event '${action.event}'.`, `${path}.with.${inputId}`);
}
}
this.diagnostics?.info?.('INFO_EVENT_INVOKED', `Event '${action.event}' invoked.`, { exhibitId: this.engine.document.meta?.id, section: 'events', objectId: action.event });
const results = this.execute(eventDef.actions, {
...context,
depth,
inputScope,
owner: context.owner
});
return { status: 'executed', type: 'event', event: action.event, results };
}
if (action.type === 'sound') {
const soundId = action.sound;
const targetSound = this.engine.document.sounds?.[soundId];
if (!targetSound) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Referenced sound '${soundId}' does not exist.`, `${path}.sound`);
}
const allowedUsage = targetSound.usage ?? ['automatic'];
const callerUsage = context.usage ?? (context.domain === 'cadence' ? 'automatic' : 'manual');
if (!allowedUsage.includes(callerUsage)) {
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Sound '${soundId}' does not permit usage '${callerUsage}'.`, `${path}.sound`);
}
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;
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`);
}
}
+9
View File
@@ -40,6 +40,15 @@ export function interpolateAutomation(curve, v0, v1, t) {
export function automationValueAt(track, milliseconds) {
const points = track.points;
if (track.loop) {
const period = points.at(-1).at;
const pingPong = track.loop.mode === 'ping-pong';
const cycle = period * (pingPong ? 2 : 1);
const count = track.loop.count ?? 'infinite';
if (count !== 'infinite' && milliseconds >= cycle * count) return pingPong ? points[0].value : points.at(-1).value;
milliseconds = Math.max(0, milliseconds) % cycle;
if (pingPong && milliseconds > period) milliseconds = cycle - milliseconds;
}
if (milliseconds <= points[0].at) return points[0].value;
for (let index = 1; index < points.length; index += 1) {
const left = points[index - 1], right = points[index];
+10 -6
View File
@@ -63,10 +63,10 @@ export function instantiateSoundGraph(document, soundId, options = {}) {
const resolver = new ValueResolver((reference) => {
if (typeof reference === 'string' && reference.startsWith('inputs.')) {
const scope = resolver.currentScope;
const values = componentValues.get(scope);
const values = scope ? componentValues.get(scope) : options.inputs;
const name = reference.slice('inputs.'.length);
if (!values || !Object.hasOwn(values, name)) {
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component input '${name}' is not available here.`);
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Input '${name}' is not available here.`);
}
return values[name];
}
@@ -545,7 +545,8 @@ export class SoundInstance {
subsystem = null,
bus = null,
context = null,
creationOrder = 0
creationOrder = 0,
owner = null
} = {}) {
this.soundId = soundId;
this.mode = mode;
@@ -556,6 +557,7 @@ export class SoundInstance {
this.bus = bus;
this.context = context;
this.creationOrder = creationOrder;
this.owner = owner;
this.state = 'CREATED';
this.realized = null;
this.startTime = null;
@@ -898,7 +900,7 @@ export class AudioSubsystem {
return next;
}
play(soundId, { resolveReference = (path) => this.resolution.get(path) } = {}) {
play(soundId, { resolveReference = (path) => this.resolution.get(path), inputs = null, owner = null } = {}) {
if (!this.unlocked) return null;
const sound = this.document?.sounds?.[soundId];
if (!sound) return null;
@@ -955,7 +957,8 @@ export class AudioSubsystem {
rng: this.rng,
ordinal: this.nextOrdinal(soundId),
resolveReference,
expansion
expansion,
inputs
});
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
if (plan.errors.length > 0) return null;
@@ -970,7 +973,8 @@ export class AudioSubsystem {
subsystem: this,
bus: this.busFor(soundId),
context: this.context,
creationOrder: this.nextCreationOrder++
creationOrder: this.nextCreationOrder++,
owner
});
pool.add(instance);
+322
View File
@@ -0,0 +1,322 @@
import { ID_PATTERN } from './constants.js';
import {
DURATION_PATTERN,
PARAMETER_TYPES,
parseDuration,
isRecord
} from './types.js';
export const CADENCE_CLASSES = Object.freeze([
'ambient',
'routine',
'intermittent',
'occasional',
'rare',
'scenario'
]);
export const SOUND_USAGE = Object.freeze([
'automatic',
'manual',
'scenario'
]);
export const ALLOWED_CADENCE_FIELDS = Object.freeze([
'intensity',
'minGap',
'clocks'
]);
export const ALLOWED_CLOCK_CLASSES = Object.freeze([
'routine',
'intermittent',
'occasional',
'rare'
]);
export const ALLOWED_SOUND_CADENCE_FIELDS = Object.freeze([
'class',
'weight',
'cooldown',
'overlap',
'when'
]);
export const ALLOWED_EVENT_FIELDS = Object.freeze([
'inputs',
'actions'
]);
export const ALLOWED_EVENT_INPUT_FIELDS = Object.freeze([
'type',
'default',
'min',
'max',
'step',
'values',
'label',
'unit'
]);
export function validateCadenceSubsystem(document, errors, helpers = {}) {
const pushError = helpers.pushError ?? ((errs, code, path, message) => errs.push({ code, path, message }));
const validateValueSpec = helpers.validateValueSpec ?? (() => {});
const validateCondition = helpers.validateCondition ?? (() => {});
// 1. Validate top-level cadence container
if (document.cadence !== undefined) {
if (!isRecord(document.cadence)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.cadence', 'cadence must be an object.');
} else {
for (const field of Object.keys(document.cadence)) {
if (!ALLOWED_CADENCE_FIELDS.includes(field)) {
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.${field}`, `Unrecognized field '${field}' in cadence.`);
}
}
if (document.cadence.intensity !== undefined) {
validateValueSpec(document, document.cadence.intensity, '$.cadence.intensity', errors);
}
if (document.cadence.minGap !== undefined) {
if (typeof document.cadence.minGap !== 'string' || !DURATION_PATTERN.test(document.cadence.minGap)) {
pushError(errors, 'ERR_INVALID_DURATION', '$.cadence.minGap', 'minGap must be a valid DurationSpec string.');
}
}
if (document.cadence.clocks !== undefined) {
if (!isRecord(document.cadence.clocks)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.cadence.clocks', 'clocks must be an object.');
} else {
for (const [className, range] of Object.entries(document.cadence.clocks)) {
if (!ALLOWED_CLOCK_CLASSES.includes(className)) {
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.clocks.${className}`, `Unrecognized cadence clock class '${className}'.`);
} else if (!isRecord(range)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.cadence.clocks.${className}`, 'Clock range must be an object.');
} else {
for (const field of Object.keys(range)) {
if (field !== 'min' && field !== 'max') {
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.clocks.${className}.${field}`, `Unrecognized field '${field}' in clock range.`);
}
}
if (range.min === undefined || range.max === undefined) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.cadence.clocks.${className}`, 'Clock range requires min and max.');
} else {
const validMin = typeof range.min === 'string' && DURATION_PATTERN.test(range.min);
const validMax = typeof range.max === 'string' && DURATION_PATTERN.test(range.max);
if (!validMin) pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}.min`, 'min must be a valid DurationSpec.');
if (!validMax) pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}.max`, 'max must be a valid DurationSpec.');
if (validMin && validMax) {
try {
const minMs = parseDuration(range.min);
const maxMs = parseDuration(range.max);
if (minMs > maxMs) {
pushError(errors, 'ERR_INVALID_RANGE_ORDER', `$.cadence.clocks.${className}`, `Clock min (${range.min}) cannot exceed max (${range.max}).`);
}
} catch (e) {
pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}`, e.message);
}
}
}
}
}
}
}
}
}
// 2. Validate sound cadence metadata
if (document.sounds !== undefined && isRecord(document.sounds)) {
for (const [soundId, sound] of Object.entries(document.sounds)) {
if (!isRecord(sound)) continue;
if (sound.cadence !== undefined) {
if (!isRecord(sound.cadence)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence`, 'cadence must be an object.');
continue;
}
for (const field of Object.keys(sound.cadence)) {
if (!ALLOWED_SOUND_CADENCE_FIELDS.includes(field)) {
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.sounds.${soundId}.cadence.${field}`, `Unrecognized sound cadence field '${field}'.`);
}
}
if (sound.cadence.class === undefined) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.class`, 'cadence.class is required.');
} else if (!CADENCE_CLASSES.includes(sound.cadence.class)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.class`, `Invalid cadence class '${sound.cadence.class}'.`);
}
if (sound.cadence.weight !== undefined) {
if (typeof sound.cadence.weight === 'number') {
if (!Number.isFinite(sound.cadence.weight) || sound.cadence.weight < 0) {
pushError(errors, 'ERR_OUT_OF_BOUNDS', `$.sounds.${soundId}.cadence.weight`, 'Cadence weight cannot be negative.');
}
} else {
validateValueSpec(document, sound.cadence.weight, `$.sounds.${soundId}.cadence.weight`, errors);
}
}
if (sound.cadence.cooldown !== undefined) {
if (typeof sound.cadence.cooldown !== 'string' || !DURATION_PATTERN.test(sound.cadence.cooldown)) {
pushError(errors, 'ERR_INVALID_DURATION', `$.sounds.${soundId}.cadence.cooldown`, 'cooldown must be a valid DurationSpec.');
}
}
if (sound.cadence.overlap !== undefined && typeof sound.cadence.overlap !== 'boolean') {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.overlap`, 'overlap must be a boolean.');
}
if (sound.cadence.when !== undefined) {
validateCondition(document, sound.cadence.when, `$.sounds.${soundId}.cadence.when`, errors);
}
}
}
}
// 3. Validate events container & event definitions
const eventGraph = new Map();
if (document.events !== undefined) {
if (!isRecord(document.events)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.events', 'events must be an object.');
} else {
for (const [eventId, eventDef] of Object.entries(document.events)) {
if (!ID_PATTERN.test(eventId)) {
pushError(errors, 'ERR_INVALID_ID', `$.events.${eventId}`, `Invalid event identifier '${eventId}'.`);
}
if (!isRecord(eventDef)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}`, 'Event definition must be an object.');
continue;
}
eventGraph.set(eventId, new Set());
for (const field of Object.keys(eventDef)) {
if (!ALLOWED_EVENT_FIELDS.includes(field)) {
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.events.${eventId}.${field}`, `Unrecognized field '${field}' in event.`);
}
}
if (eventDef.inputs !== undefined) {
if (!isRecord(eventDef.inputs)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs`, 'Event inputs must be an object.');
} else {
for (const [inputId, inputSpec] of Object.entries(eventDef.inputs)) {
if (!ID_PATTERN.test(inputId)) {
pushError(errors, 'ERR_INVALID_ID', `$.events.${eventId}.inputs.${inputId}`, `Invalid input identifier '${inputId}'.`);
}
if (!isRecord(inputSpec)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs.${inputId}`, 'Input spec must be an object.');
} else {
for (const field of Object.keys(inputSpec)) {
if (!ALLOWED_EVENT_INPUT_FIELDS.includes(field)) {
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.events.${eventId}.inputs.${inputId}.${field}`, `Unrecognized field '${field}' in input spec.`);
}
}
if (!PARAMETER_TYPES.includes(inputSpec.type)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs.${inputId}.type`, `Invalid input type '${inputSpec.type}'.`);
}
}
}
}
}
if (!Array.isArray(eventDef.actions) || eventDef.actions.length === 0) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.actions`, 'actions must be a non-empty array.');
} else {
validateEventActions(document, eventDef.actions, `$.events.${eventId}.actions`, eventId, errors, helpers, eventGraph, eventDef.inputs ?? {});
}
}
// 4. Static cycle detection in event dependency graph
const visiting = new Set();
const visited = new Set();
const detectCycle = (node, trail) => {
if (visiting.has(node)) {
const cycleTrail = [...trail, node].join(' -> ');
pushError(errors, 'ERR_CYCLIC_DEPENDENCY', `$.events.${node}`, `Cyclic event invocation detected: ${cycleTrail}.`);
return;
}
if (visited.has(node)) return;
visiting.add(node);
const targets = eventGraph.get(node) ?? new Set();
for (const target of targets) {
if (eventGraph.has(target)) {
detectCycle(target, [...trail, node]);
}
}
visiting.delete(node);
visited.add(node);
};
for (const eventId of eventGraph.keys()) {
detectCycle(eventId, []);
}
}
}
}
function validateEventActions(document, actions, path, owningEventId, errors, helpers, eventGraph, localInputs) {
const pushError = helpers.pushError ?? ((errs, code, p, m) => errs.push({ code, path: p, message: m }));
const validateValueSpec = helpers.validateValueSpec ?? (() => {});
const validateCondition = helpers.validateCondition ?? (() => {});
actions.forEach((action, index) => {
const actionPath = `${path}[${index}]`;
if (!isRecord(action)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', actionPath, 'Action must be an object.');
return;
}
if (typeof action.type !== 'string') {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.type`, 'Action requires a type.');
return;
}
if (action.id !== undefined && !ID_PATTERN.test(action.id)) {
pushError(errors, 'ERR_INVALID_ID', `${actionPath}.id`, `Invalid action identifier '${action.id}'.`);
}
if (action.when !== undefined) {
validateCondition(document, action.when, `${actionPath}.when`, errors);
}
if (action.chance !== undefined) {
if (typeof action.chance !== 'number' || !Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) {
pushError(errors, 'ERR_OUT_OF_BOUNDS', `${actionPath}.chance`, 'Action chance must be between 0 and 1.');
}
}
if (action.critical !== undefined && typeof action.critical !== 'boolean') {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.critical`, 'critical must be a boolean.');
}
if (action.type === 'event') {
if (typeof action.event !== 'string') {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.event`, 'Event action requires an event identifier.');
} else {
if (!document.events?.[action.event]) {
pushError(errors, 'ERR_INVALID_REFERENCE', `${actionPath}.event`, `Referenced event '${action.event}' does not exist.`);
} else {
eventGraph.get(owningEventId)?.add(action.event);
}
if (action.with !== undefined) {
if (!isRecord(action.with)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.with`, 'with must be an object.');
} else {
for (const [paramName, valSpec] of Object.entries(action.with)) {
validateValueSpec(document, valSpec, `${actionPath}.with.${paramName}`, errors, { inputs: localInputs });
}
}
}
}
} else if (action.type === 'sound') {
if (typeof action.sound !== 'string') {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.sound`, 'Sound action requires a sound identifier.');
} else {
if (!document.sounds?.[action.sound]) {
pushError(errors, 'ERR_INVALID_REFERENCE', `${actionPath}.sound`, `Referenced sound '${action.sound}' does not exist.`);
}
if (action.with !== undefined && !isRecord(action.with)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.with`, 'with must be an object.');
}
if (action.ownership !== undefined && !['performance', 'scenario', 'persistent'].includes(action.ownership)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.ownership`, `Invalid sound action ownership '${action.ownership}'.`);
}
}
}
});
}
+327
View File
@@ -0,0 +1,327 @@
import { parseDuration, clamp } from './types.js';
import { ValueResolver, ConditionEvaluator } from './values.js';
export const DEFAULT_CLASS_INTERVALS = Object.freeze({
routine: Object.freeze({ min: 10_000, max: 45_000 }),
intermittent: Object.freeze({ min: 45_000, max: 240_000 }),
occasional: Object.freeze({ min: 180_000, max: 900_000 }),
rare: Object.freeze({ min: 900_000, max: 3_600_000 })
});
export const CADENCE_PRIORITY = Object.freeze(['rare', 'occasional', 'intermittent', 'routine']);
export const RECENCY_MULTIPLIERS = Object.freeze([0.0, 0.25, 0.50, 0.75]);
export class CadenceSubsystem {
constructor({ document, rng, resolution, audio = null, diagnostics = null } = {}) {
this.document = document;
this.rng = rng;
this.resolution = resolution;
this.audio = audio;
this.diagnostics = diagnostics;
this.intensity = 1.0;
this.minGapMs = 1500;
if (document?.cadence?.minGap) {
try {
this.minGapMs = parseDuration(document.cadence.minGap);
} catch {
this.minGapMs = 1500;
}
}
this.logicalMilliseconds = 0;
this.lastAutomaticSoundTime = -Infinity;
this.lastFiredTimes = new Map();
this.ambientInstances = new Map(); // soundId -> voice instance
this.classes = {};
for (const className of CADENCE_PRIORITY) {
let range = DEFAULT_CLASS_INTERVALS[className];
if (document?.cadence?.clocks?.[className]) {
try {
const authored = document.cadence.clocks[className];
range = {
min: parseDuration(authored.min),
max: parseDuration(authored.max)
};
} catch {
range = DEFAULT_CLASS_INTERVALS[className];
}
}
this.classes[className] = {
name: className,
range,
recencyHistory: [],
ordinal: 0,
timer: 0,
due: false
};
}
this.initializeTimers();
}
setAudio(audio) {
this.audio = audio;
}
initializeTimers() {
this.updateIntensity();
for (const className of CADENCE_PRIORITY) {
const cls = this.classes[className];
cls.ordinal += 1;
const stream = this.rng.stream('cadence', `${className}:interval:${cls.ordinal}`);
const baseMs = cls.range.min + stream.nextFloat() * (cls.range.max - cls.range.min);
cls.timer = this.intensity > 1e-6 ? baseMs / this.intensity : Infinity;
cls.due = false;
}
}
setIntensity(value) {
this.intensity = clamp(value, 0, 10);
}
getEffectiveInterval(className) {
const range = this.classes[className]?.range ?? DEFAULT_CLASS_INTERVALS[className];
const intensity = Math.max(1e-6, this.intensity);
return {
min: Math.max(100, Math.round(range.min / intensity)),
max: Math.max(100, Math.round(range.max / intensity))
};
}
updateIntensity() {
if (this.document?.cadence?.intensity !== undefined && this.resolution) {
try {
const stream = this.rng.stream('cadence', `intensity:${this.logicalMilliseconds}`);
const resolved = this.resolution.evaluateValue(this.document.cadence.intensity, stream, '$.cadence.intensity');
if (Number.isFinite(resolved)) {
this.intensity = clamp(resolved, 0, 1);
}
} catch {
this.intensity = 1.0;
}
} else {
this.intensity = 1.0;
}
}
hasActiveVoice(soundId) {
if (!this.audio) return false;
const activeStates = ['SCHEDULED', 'ACTIVE', 'RELEASING'];
if (typeof this.audio.getActiveSounds === 'function') {
for (const active of this.audio.getActiveSounds()) {
if (active.soundId === soundId) return true;
}
}
if (this.audio.activeInstances instanceof Map) {
for (const inst of this.audio.activeInstances.values()) {
if (inst.soundId === soundId && !inst.disposed) return true;
}
}
if (this.audio.voices) {
for (const voice of this.audio.voices) {
if (voice.soundId === soundId && activeStates.includes(voice.state)) return true;
}
}
for (const voice of this.audio.oneshotVoices ?? []) {
if (voice.soundId === soundId && activeStates.includes(voice.state)) return true;
}
for (const voice of this.audio.continuousVoices ?? []) {
if (voice.soundId === soundId && activeStates.includes(voice.state)) return true;
}
return false;
}
update(timeMs) {
const step = timeMs - this.logicalMilliseconds;
this.advance(Math.max(0, step));
}
getLastEvaluatedPool(className) {
return this.classes[className]?.lastEvaluatedPool ?? [];
}
advance(stepMs) {
this.logicalMilliseconds += stepMs;
this.updateIntensity();
// 1. Advance ambient maintenance
this.maintainAmbience();
// 2. If intensity <= 0, pause automatic one-shot scheduling
if (this.intensity <= 1e-6) {
return;
}
// 3. Advance class timers
for (const className of CADENCE_PRIORITY) {
const cls = this.classes[className];
if (!cls.due) {
cls.timer -= stepMs;
if (cls.timer <= 1e-7) {
cls.due = true;
}
}
}
// 4. Check minGap and service highest priority due class
if (this.logicalMilliseconds - this.lastAutomaticSoundTime >= this.minGapMs) {
for (const className of CADENCE_PRIORITY) {
const cls = this.classes[className];
if (cls.due) {
const fired = this.fireClass(cls);
if (fired) {
this.lastAutomaticSoundTime = this.logicalMilliseconds;
cls.due = false;
// Reschedule next timer
cls.ordinal += 1;
const stream = this.rng.stream('cadence', `${className}:interval:${cls.ordinal}`);
const baseMs = cls.range.min + stream.nextFloat() * (cls.range.max - cls.range.min);
cls.timer = baseMs / Math.max(1e-6, this.intensity);
break; // Only one class sound per gap window
}
}
}
}
}
maintainAmbience() {
if (!this.audio) return;
if (this.audio.unlocked === false) return;
const sounds = this.document?.sounds ?? {};
for (const [soundId, sound] of Object.entries(sounds)) {
if (sound.cadence?.class === 'ambient') {
const allowedUsage = sound.usage ?? ['automatic'];
if (!allowedUsage.includes('automatic')) continue;
const currentVoice = this.ambientInstances.get(soundId);
const isRunning = currentVoice && !currentVoice.disposed && (currentVoice.state === undefined || currentVoice.state === 'ACTIVE' || currentVoice.state === 'SCHEDULED');
if (!isRunning) {
const voice = this.audio.play(soundId, { owner: 'performance' });
if (voice) {
this.ambientInstances.set(soundId, voice);
}
}
}
}
}
fireClass(cls) {
const className = cls.name;
const sounds = this.document?.sounds ?? {};
const eligible = [];
for (const [soundId, sound] of Object.entries(sounds)) {
if (sound.cadence?.class !== className) continue;
const allowedUsage = sound.usage ?? ['automatic'];
if (!allowedUsage.includes('automatic')) continue;
// Check condition
if (sound.cadence.when !== undefined && this.resolution) {
const condStream = this.rng.stream('cadence', `${className}:when:${soundId}:${cls.ordinal}`);
if (!this.resolution.evaluateCondition(sound.cadence.when, condStream, `$.sounds.${soundId}.cadence.when`)) {
continue;
}
}
// Check cooldown
const lastFired = this.lastFiredTimes.get(soundId) ?? -Infinity;
let cooldownMs = 0;
if (sound.cadence.cooldown) {
try { cooldownMs = parseDuration(sound.cadence.cooldown); } catch { cooldownMs = 0; }
}
if (this.logicalMilliseconds - lastFired < cooldownMs) {
continue;
}
// Check overlap
if (sound.cadence.overlap === false) {
if (this.hasActiveVoice(soundId)) {
continue;
}
}
eligible.push({ id: soundId, sound });
}
if (eligible.length === 0) {
return null;
}
// Evaluate base weights
const weightStream = this.rng.stream('cadence', `${className}:weight:${cls.ordinal}`);
const evaluated = [];
for (const candidate of eligible) {
let baseWeight = 1.0;
if (candidate.sound.cadence.weight !== undefined) {
if (typeof candidate.sound.cadence.weight === 'number') {
baseWeight = candidate.sound.cadence.weight;
} else if (this.resolution) {
baseWeight = this.resolution.evaluateValue(candidate.sound.cadence.weight, weightStream, `$.sounds.${candidate.id}.cadence.weight`);
}
}
baseWeight = Math.max(0, Number.isFinite(baseWeight) ? baseWeight : 0);
// Anti-repetition multiplier
const historyIndex = cls.recencyHistory.indexOf(candidate.id);
let multiplier = 1.0;
if (historyIndex >= 0 && historyIndex < RECENCY_MULTIPLIERS.length) {
multiplier = RECENCY_MULTIPLIERS[historyIndex];
}
evaluated.push({
id: candidate.id,
sound: candidate.sound,
baseWeight,
effectiveWeight: baseWeight * multiplier
});
}
let totalWeight = evaluated.reduce((sum, item) => sum + item.effectiveWeight, 0);
// Pool relaxation: if all effective weights are 0, relax penalties to base weights
if (totalWeight <= 1e-9) {
for (const item of evaluated) {
item.effectiveWeight = item.baseWeight;
}
totalWeight = evaluated.reduce((sum, item) => sum + item.effectiveWeight, 0);
}
cls.lastEvaluatedPool = evaluated;
if (totalWeight <= 1e-9) {
return null;
}
// Weighted random selection
const selectStream = this.rng.stream('cadence', `${className}:select:${cls.ordinal}`);
const roll = selectStream.nextFloat() * totalWeight;
let accumulated = 0;
let chosen = null;
for (const item of evaluated) {
if (item.effectiveWeight <= 1e-9) continue;
accumulated += item.effectiveWeight;
if (roll <= accumulated) {
chosen = item;
break;
}
}
if (!chosen) {
chosen = evaluated.find(item => item.effectiveWeight > 1e-9) ?? evaluated[0];
}
// Play chosen sound
this.lastFiredTimes.set(chosen.id, this.logicalMilliseconds);
cls.recencyHistory.unshift(chosen.id);
if (cls.recencyHistory.length > 4) {
cls.recencyHistory.pop();
}
if (this.audio) {
this.audio.play(chosen.id, { owner: 'performance' });
}
return chosen.id;
}
}