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
166 lines
6.4 KiB
JavaScript
166 lines
6.4 KiB
JavaScript
import { ActionExecutor } from './actions.js';
|
|
import { SeededRNG } from './rng.js';
|
|
import { ResolutionEngine } from './resolution.js';
|
|
import { CadenceSubsystem } from './cadence.js';
|
|
import { ScenarioDirector } from './scenario.js';
|
|
|
|
export class CommonGrammarPerformance {
|
|
constructor(record, rootSeed, options = {}) {
|
|
this.record = record;
|
|
this.rootSeed = rootSeed;
|
|
this.rng = new SeededRNG(rootSeed);
|
|
this.diagnostics = options.diagnostics;
|
|
this.onUpdate = options.onUpdate ?? (() => {});
|
|
// Called once per animation frame, after any logical ticks. A frame draws
|
|
// the most recent tick's state and advances nothing (9.1, 18.2).
|
|
this.onFrame = options.onFrame ?? (() => {});
|
|
// Called once per logical tick, with the fixed step of 9.1. Procedural
|
|
// motion advances here and nowhere else.
|
|
this.onTick = options.onTick ?? (() => {});
|
|
this.engine = new ResolutionEngine(record.document, this.rng, {
|
|
diagnostics: this.diagnostics,
|
|
initialParameters: options.initialParameters,
|
|
onParameterChange: options.onParameterChange
|
|
});
|
|
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
|
|
this.actions.audio = options.audio ?? null;
|
|
if (this.actions.audio) this.actions.audio.useLogicalClock = true;
|
|
this.scenarios = new ScenarioDirector({ document: record.document, resolution: this.engine, actions: this.actions, rng: this.rng, diagnostics: this.diagnostics, onTrace: options.onScenarioTrace });
|
|
this.cadence = new CadenceSubsystem({
|
|
document: record.document,
|
|
rng: this.rng,
|
|
resolution: this.engine,
|
|
audio: options.audio ?? null,
|
|
diagnostics: this.diagnostics
|
|
});
|
|
this.state = 'prepared';
|
|
this.resources = new Set();
|
|
this.frameRequest = null;
|
|
this.lastFrame = undefined;
|
|
this.accumulator = 0;
|
|
this.pauseReasons = new Set();
|
|
this.boundFrame = (now) => this.frame(now);
|
|
this.boundPointer = (event) => {
|
|
this.engine.signals.set('signals.pointer.x', event.clientX);
|
|
this.engine.signals.set('signals.pointer.y', event.clientY);
|
|
this.engine.invalidate();
|
|
};
|
|
this.boundResize = () => {
|
|
this.engine.signals.set('signals.viewport.width', globalThis.innerWidth ?? 0);
|
|
this.engine.signals.set('signals.viewport.height', globalThis.innerHeight ?? 0);
|
|
this.engine.invalidate();
|
|
};
|
|
}
|
|
|
|
async activate() {
|
|
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
|
|
this.state = 'active';
|
|
this.onUpdate(this.engine);
|
|
if (typeof globalThis.addEventListener === 'function') {
|
|
globalThis.addEventListener('pointermove', this.boundPointer, { passive: true });
|
|
globalThis.addEventListener('resize', this.boundResize, { passive: true });
|
|
}
|
|
if (typeof globalThis.requestAnimationFrame === 'function') this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
|
}
|
|
|
|
frame(now) {
|
|
if (this.state !== 'active') return;
|
|
if (this.lastFrame === undefined) this.lastFrame = now;
|
|
const observed = Math.max(0, now - this.lastFrame);
|
|
this.lastFrame = now;
|
|
const accepted = Math.min(observed, 250);
|
|
if (this.pauseReasons.size) {
|
|
this.onFrame(this.engine);
|
|
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
|
return;
|
|
}
|
|
this.accumulator += accepted;
|
|
const step = 1000 / 60;
|
|
let ticks = 0;
|
|
while (this.accumulator + 1e-9 >= step && ticks < 8) {
|
|
this.tick();
|
|
this.accumulator -= step;
|
|
ticks += 1;
|
|
}
|
|
if (observed > 250 || this.accumulator >= step) {
|
|
this.accumulator = Math.min(this.accumulator, step - 1e-9);
|
|
this.diagnostics?.warn('WARN_CLOCK_STALL', 'Discarded excess elapsed time to preserve the fixed-step work bound.', { exhibitId: this.record.id, section: 'scheduler' });
|
|
}
|
|
if (ticks > 0) this.onUpdate(this.engine);
|
|
this.onFrame(this.engine);
|
|
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
|
}
|
|
|
|
setAudio(audio) {
|
|
this.actions.audio = audio;
|
|
if (audio) audio.useLogicalClock = true;
|
|
this.cadence.setAudio(audio);
|
|
}
|
|
|
|
tick() {
|
|
if (this.pauseReasons.size || this.scenarios.disposed) return;
|
|
const step = 1000 / 60;
|
|
this.actions.resetBudget();
|
|
this.engine.advance(step);
|
|
this.actions.audio?.advanceLogical?.();
|
|
this.cadence.advance(step);
|
|
this.scenarios.update();
|
|
this.onTick(step, this.engine);
|
|
}
|
|
|
|
// Development acceleration executes the same fixed ticks without rendering.
|
|
advanceTicks(count) {
|
|
if (!Number.isSafeInteger(count) || count < 0) throw new RangeError('Tick count must be a nonnegative safe integer.');
|
|
for (let i = 0; i < count; i++) this.tick();
|
|
}
|
|
|
|
pause(reason = 'user') { this.pauseReasons.add(reason); this.lastFrame = undefined; this.accumulator = 0; }
|
|
resume(reason = 'user') { this.pauseReasons.delete(reason); this.lastFrame = undefined; this.accumulator = 0; }
|
|
|
|
setParameter(id, value) {
|
|
const result = this.engine.setParameter(id, value);
|
|
this.onUpdate(this.engine);
|
|
return result;
|
|
}
|
|
|
|
execute(actionArray, context) {
|
|
const result = this.actions.execute(actionArray, context);
|
|
this.onUpdate(this.engine);
|
|
return result;
|
|
}
|
|
|
|
releaseOverride(id) {
|
|
const released = this.engine.overrides.beginRelease(id);
|
|
this.engine.resolveAll();
|
|
this.onUpdate(this.engine);
|
|
return released;
|
|
}
|
|
|
|
async deactivate() {
|
|
this.scenarios.dispose();
|
|
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
|
|
this.frameRequest = null;
|
|
this.lastFrame = undefined;
|
|
if (typeof globalThis.removeEventListener === 'function') {
|
|
globalThis.removeEventListener('pointermove', this.boundPointer);
|
|
globalThis.removeEventListener('resize', this.boundResize);
|
|
}
|
|
if (this.state === 'active') this.state = 'inactive';
|
|
this.engine.overrides.clear();
|
|
this.resources.clear();
|
|
}
|
|
|
|
async dispose() {
|
|
this.scenarios.dispose();
|
|
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
|
|
this.frameRequest = null;
|
|
if (typeof globalThis.removeEventListener === 'function') {
|
|
globalThis.removeEventListener('pointermove', this.boundPointer);
|
|
globalThis.removeEventListener('resize', this.boundResize);
|
|
}
|
|
this.engine.dispose();
|
|
this.resources.clear();
|
|
this.state = 'disposed';
|
|
}
|
|
}
|