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:
+152
-5
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user