@proophboard/exploration-runtime 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/index.d.ts +68 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +61 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/lib/connectionRules.d.ts +39 -0
  6. package/dist/lib/connectionRules.d.ts.map +1 -0
  7. package/dist/lib/connectionRules.js +475 -0
  8. package/dist/lib/connectionRules.js.map +1 -0
  9. package/dist/lib/toClassName.d.ts +42 -0
  10. package/dist/lib/toClassName.d.ts.map +1 -0
  11. package/dist/lib/toClassName.js +81 -0
  12. package/dist/lib/toClassName.js.map +1 -0
  13. package/dist/playFunction/ambientTypes.d.ts +30 -0
  14. package/dist/playFunction/ambientTypes.d.ts.map +1 -0
  15. package/dist/playFunction/ambientTypes.js +174 -0
  16. package/dist/playFunction/ambientTypes.js.map +1 -0
  17. package/dist/playFunction/cascadeBudget.d.ts +53 -0
  18. package/dist/playFunction/cascadeBudget.d.ts.map +1 -0
  19. package/dist/playFunction/cascadeBudget.js +74 -0
  20. package/dist/playFunction/cascadeBudget.js.map +1 -0
  21. package/dist/playFunction/clock.d.ts +58 -0
  22. package/dist/playFunction/clock.d.ts.map +1 -0
  23. package/dist/playFunction/clock.js +104 -0
  24. package/dist/playFunction/clock.js.map +1 -0
  25. package/dist/playFunction/modelTypes.d.ts +74 -0
  26. package/dist/playFunction/modelTypes.d.ts.map +1 -0
  27. package/dist/playFunction/modelTypes.js +345 -0
  28. package/dist/playFunction/modelTypes.js.map +1 -0
  29. package/dist/playFunction/playLog.d.ts +29 -0
  30. package/dist/playFunction/playLog.d.ts.map +1 -0
  31. package/dist/playFunction/playLog.js +64 -0
  32. package/dist/playFunction/playLog.js.map +1 -0
  33. package/dist/playFunction/playType.d.ts +52 -0
  34. package/dist/playFunction/playType.d.ts.map +1 -0
  35. package/dist/playFunction/playType.js +94 -0
  36. package/dist/playFunction/playType.js.map +1 -0
  37. package/dist/playFunction/runtimeFold.d.ts +51 -0
  38. package/dist/playFunction/runtimeFold.d.ts.map +1 -0
  39. package/dist/playFunction/runtimeFold.js +620 -0
  40. package/dist/playFunction/runtimeFold.js.map +1 -0
  41. package/dist/playFunction/runtimeSteps.d.ts +45 -0
  42. package/dist/playFunction/runtimeSteps.d.ts.map +1 -0
  43. package/dist/playFunction/runtimeSteps.js +112 -0
  44. package/dist/playFunction/runtimeSteps.js.map +1 -0
  45. package/dist/playFunction/sandbox.d.ts +52 -0
  46. package/dist/playFunction/sandbox.d.ts.map +1 -0
  47. package/dist/playFunction/sandbox.js +91 -0
  48. package/dist/playFunction/sandbox.js.map +1 -0
  49. package/dist/playFunction/scenarioRunner.d.ts +49 -0
  50. package/dist/playFunction/scenarioRunner.d.ts.map +1 -0
  51. package/dist/playFunction/scenarioRunner.js +331 -0
  52. package/dist/playFunction/scenarioRunner.js.map +1 -0
  53. package/dist/playFunction/transpile.d.ts +33 -0
  54. package/dist/playFunction/transpile.d.ts.map +1 -0
  55. package/dist/playFunction/transpile.js +71 -0
  56. package/dist/playFunction/transpile.js.map +1 -0
  57. package/dist/types/eventModel.d.ts +79 -0
  58. package/dist/types/eventModel.d.ts.map +1 -0
  59. package/dist/types/eventModel.js +12 -0
  60. package/dist/types/eventModel.js.map +1 -0
  61. package/dist/types/exploration.d.ts +143 -0
  62. package/dist/types/exploration.d.ts.map +1 -0
  63. package/dist/types/exploration.js +28 -0
  64. package/dist/types/exploration.js.map +1 -0
  65. package/package.json +48 -4
  66. package/README.md +0 -3
@@ -0,0 +1,620 @@
1
+ /**
2
+ * runtimeFold — the pure, environment-independent Exploration fold that executes
3
+ * AUTHORED play functions (4b), falling back to the built-in defaults (R11) when
4
+ * an element has no `playFunction`.
5
+ *
6
+ * This is deliberately NOT a hook and touches no DOM/worker API, so it is:
7
+ * - unit-testable directly (the Web Worker can't be exercised under jsdom), and
8
+ * - usable both inside the Worker (handlerWorker.ts) and on the main-thread
9
+ * synchronous fast-path (when no element has a playFunction).
10
+ *
11
+ * Contract parity: with no authored handlers this reproduces `runReplay`'s exact
12
+ * default behaviour (so the M1–M3 runtime contract/tests still hold). Authored
13
+ * handlers extend it with: six handler kinds (interact/decide/apply/read/react/
14
+ * process), ordered multi-event commands, sequential apply, two-level leaf-replace
15
+ * merge, non-object Information shapes (via apply), deterministic clock + uuid(),
16
+ * the per-step cascade budget, and reject().
17
+ *
18
+ * Handler sources arrive PRE-TRANSPILED (TS→JS done by the caller/worker), so the
19
+ * fold stays synchronous. Compilation (`new Function`) is cached per source.
20
+ */
21
+ import { toClassName } from '../lib/toClassName.js';
22
+ import { getCommandsDrivenByUi, getConnections } from '../lib/connectionRules.js';
23
+ import { deriveRuntimeSteps, mergeProjection, seedState, defaultDecide, } from './runtimeSteps.js';
24
+ import { DeterministicClock } from './clock.js';
25
+ import { CascadeBudget, isCascadeBudgetExceeded, DEFAULT_CASCADE_LIMITS } from './cascadeBudget.js';
26
+ import { compileHandler, runHandler, deepFreeze, isRejectSignal, } from './sandbox.js';
27
+ import { makePlayLogger } from './playLog.js';
28
+ function clone(value) {
29
+ return value == null ? value : JSON.parse(JSON.stringify(value));
30
+ }
31
+ function isPlainObject(v) {
32
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
33
+ }
34
+ /** Compiled-handler cache keyed by source (compilation is pure). */
35
+ const compiledCache = new Map();
36
+ function getCompiled(source, kind) {
37
+ const cacheKey = `${kind}::${source}`;
38
+ let fn = compiledCache.get(cacheKey);
39
+ if (!fn) {
40
+ fn = compileHandler(source, kind);
41
+ compiledCache.set(cacheKey, fn);
42
+ }
43
+ return fn;
44
+ }
45
+ /** Test seam. */
46
+ export function __clearCompiledCache() {
47
+ compiledCache.clear();
48
+ }
49
+ /** Does any element in the chapter carry an authored play function? */
50
+ export function hasAnyPlayFunction(chapter) {
51
+ if (!chapter)
52
+ return false;
53
+ return chapter.elements.some((e) => typeof e.playFunction === 'string' && e.playFunction.trim().length > 0);
54
+ }
55
+ /**
56
+ * Run the authored-handler fold. Pure: `(input) → RuntimeResultV2`.
57
+ */
58
+ export function runFold(input) {
59
+ const { chapter, steps, scenario, playheadIndex, liveStorage, handlers } = input;
60
+ const log = makePlayLogger(input.logSource ?? 'PlayWorker');
61
+ const authoredCount = Object.keys(handlers).length;
62
+ log('flow', `fold start — playhead=${playheadIndex}, steps=${steps.length}, authoredHandlers=${authoredCount}`);
63
+ const clock = new DeterministicClock(scenario.clock);
64
+ const budget = new CascadeBudget(input.cascadeLimits ?? DEFAULT_CASCADE_LIMITS);
65
+ const errors = [];
66
+ // Automation-dispatched commands awaiting their slice (deferred execution).
67
+ const pendingCommands = [];
68
+ // Pending commands already fired this replay (so we don't fire twice).
69
+ const firedPending = new Set();
70
+ // Seed state (reuses the M1 seeder; keys are className-form).
71
+ const state = seedState(scenario, clock.now());
72
+ // Captured-input seam (navigation without a command): `state.$input` exposes
73
+ // the UI storage collected via `storage.save()` / manual capture to all
74
+ // handlers — notably Information `read(state)`, which can "query" a projection
75
+ // using a captured value (e.g. find the picked todo by `state.$input.todoId`)
76
+ // WITHOUT hijacking a command→event flow for pure navigation.
77
+ //
78
+ // Shape (Option A — flat, `$`-reserved): the top level mirrors the LAST visible
79
+ // step's storage (most recent step ≤ playhead that captured input), so
80
+ // `state.$input.todoId` reads the latest value directly; `$byStep` is a reserved
81
+ // sibling holding every visible step's storage keyed by step index. It is kept
82
+ // current as the fold advances, so `read` (computed at the playhead) sees input
83
+ // from this and all earlier visible steps — never from the future.
84
+ const inputByStep = {};
85
+ const refreshInput = () => {
86
+ // Flatten: last-captured wins at the top level; $byStep preserves history.
87
+ const stepIdx = Object.keys(inputByStep)
88
+ .map(Number)
89
+ .sort((a, b) => a - b);
90
+ const last = stepIdx.length ? inputByStep[stepIdx[stepIdx.length - 1]] : {};
91
+ state.$input = { ...last, $byStep: { ...inputByStep } };
92
+ };
93
+ refreshInput(); // seed with {} / empty $byStep
94
+ const handlerFor = (el) => {
95
+ if (!el)
96
+ return undefined;
97
+ const src = handlers[el.id];
98
+ return src && src.trim().length > 0 ? src : undefined;
99
+ };
100
+ // Frozen snapshot of state for handler input (handlers must not mutate it).
101
+ const frozenState = () => deepFreeze(clone(state));
102
+ // Event → reacting automation(s), resolved via connection rules (NOT slice
103
+ // co-membership): the triggering event may sit in the write slice BEFORE the
104
+ // automation. We index reacting automations by the event's NAME, since the
105
+ // runtime matches produced events to event elements by name.
106
+ const reactionsByEventName = new Map();
107
+ if (chapter) {
108
+ const connections = getConnections(chapter);
109
+ for (const conn of connections) {
110
+ if (conn.type !== 'event')
111
+ continue;
112
+ if (conn.fromElement.type !== 'event' || conn.toElement.type !== 'automation')
113
+ continue;
114
+ const name = conn.fromElement.name;
115
+ const list = reactionsByEventName.get(name) ?? [];
116
+ // Only reaction automations that actually author a `react` participate;
117
+ // a default (no playFunction) automation reaction is a no-op (R11).
118
+ list.push(conn.toElement);
119
+ reactionsByEventName.set(name, list);
120
+ }
121
+ }
122
+ /** Ids of automations that are event-reaction targets (so NOT jobs). */
123
+ const reactionAutomationIds = new Set();
124
+ for (const list of reactionsByEventName.values()) {
125
+ for (const a of list)
126
+ reactionAutomationIds.add(a.id);
127
+ }
128
+ // Command → event element(s) it produces, resolved via connection rules (the
129
+ // event may be in the command's slice OR a following event-only slice). Used by
130
+ // the DEFAULT decide so a command without an authored handler emits the right
131
+ // event(s) even across slices.
132
+ const eventsByCommandId = new Map();
133
+ if (chapter) {
134
+ const connections = getConnections(chapter);
135
+ for (const conn of connections) {
136
+ if (conn.type !== 'command')
137
+ continue;
138
+ if (conn.fromElement.type !== 'command' || conn.toElement.type !== 'event')
139
+ continue;
140
+ const list = eventsByCommandId.get(conn.fromElement.id) ?? [];
141
+ list.push(conn.toElement);
142
+ eventsByCommandId.set(conn.fromElement.id, list);
143
+ }
144
+ }
145
+ // Event → Information element(s) it projects into, resolved via connection rules.
146
+ // In Event Modeling the Information an event writes is frequently drawn in a
147
+ // FOLLOWING read slice (connected by an event→information edge), NOT co-located
148
+ // in the event's write slice. Keyed by the event element's NAME (produced events
149
+ // match event elements by name; the same name may recur across slices). Used by
150
+ // the DEFAULT apply so a plain event still fills its connected Information.
151
+ const informationByEventName = new Map();
152
+ if (chapter) {
153
+ const connections = getConnections(chapter);
154
+ for (const conn of connections) {
155
+ if (conn.type !== 'event')
156
+ continue;
157
+ if (conn.fromElement.type !== 'event' || conn.toElement.type !== 'information')
158
+ continue;
159
+ const name = conn.fromElement.name;
160
+ const list = informationByEventName.get(name) ?? [];
161
+ list.push(conn.toElement);
162
+ informationByEventName.set(name, list);
163
+ }
164
+ }
165
+ /** Compute Information read() views at the current state (R11a, R21a). */
166
+ const computeReadViews = (atStep) => {
167
+ const views = [];
168
+ if (!chapter)
169
+ return views;
170
+ const seen = new Set();
171
+ for (const el of chapter.elements) {
172
+ if (el.type !== 'information')
173
+ continue;
174
+ const authored = handlerFor(el);
175
+ if (!authored || seen.has(el.id))
176
+ continue;
177
+ seen.add(el.id);
178
+ const ctxKey = toClassName(el.context || 'App');
179
+ const elKey = toClassName(el.name);
180
+ try {
181
+ const view = runHandler(getCompiled(authored, 'read'), clock.surface(), [frozenState()]);
182
+ log('read', `read(state) → view for ${el.context || 'App'}.${el.name}`, view);
183
+ views.push({ elementId: el.id, key: `${ctxKey}.${elKey}`, view });
184
+ }
185
+ catch (err) {
186
+ errors.push({
187
+ kind: 'threw',
188
+ message: err instanceof Error ? err.message : String(err),
189
+ stepIndex: atStep,
190
+ elementId: el.id,
191
+ elementName: el.name,
192
+ });
193
+ }
194
+ }
195
+ return views;
196
+ };
197
+ if (!chapter || steps.length === 0) {
198
+ return { events: state.$events, state, errors, readViews: computeReadViews(0), clockISO: clock.now() };
199
+ }
200
+ const upto = Math.max(0, Math.min(playheadIndex, steps.length - 1));
201
+ // Storage per step (live overrides recorded) — same routing as runReplay.
202
+ const recorded = new Map();
203
+ for (const entry of scenario.interactions ?? []) {
204
+ if (entry && typeof entry.stepIndex === 'number')
205
+ recorded.set(entry.stepIndex, entry.storage ?? {});
206
+ }
207
+ const storageForStep = (s) => {
208
+ const live = liveStorage?.[s];
209
+ return live !== undefined ? live : recorded.get(s);
210
+ };
211
+ /** Emit one runtime event: assign index/timestamp, tick clock, record, apply. */
212
+ const emitEvent = (step, partial) => {
213
+ budget.countEvent();
214
+ const runtimeEvent = {
215
+ ...partial,
216
+ payload: partial.payload ?? {},
217
+ index: state.$events.length,
218
+ timestamp: clock.now(),
219
+ sliceId: step.slice.id, // M5 attribution: which slice emitted this event
220
+ };
221
+ state.$events.push(runtimeEvent);
222
+ log('event', `emit #${runtimeEvent.index} ${runtimeEvent.context}.${runtimeEvent.name} @${runtimeEvent.timestamp}`, runtimeEvent.payload);
223
+ clock.tick(); // auto-tick per produced event (R32-A)
224
+ // event.apply — authored or default.
225
+ applyEvent(step, runtimeEvent);
226
+ // Automation event-reactions (R10 react): fire reacting automations for this
227
+ // event, bounded by cascade DEPTH. Reactions produce commands that re-enter
228
+ // decide → events → (possibly) more reactions.
229
+ fireReactions(step, runtimeEvent);
230
+ return runtimeEvent;
231
+ };
232
+ /** Apply an event into projections: authored apply() or the default merge. */
233
+ const applyEvent = (step, event) => {
234
+ // Resolve the event element (for an authored apply) and the target Information.
235
+ const eventEl = step.events.find((e) => e.name === event.name) ?? step.events[0];
236
+ const authored = handlerFor(eventEl);
237
+ if (authored) {
238
+ try {
239
+ const patch = runHandler(getCompiled(authored, 'apply'), clock.surface(), [clone(event), frozenState()]);
240
+ log('apply', `apply(${event.name}, state) → projection patch`, patch);
241
+ if (isPlainObject(patch)) {
242
+ // Two-level leaf-replace merge of { Ctx: { El: {...} } }.
243
+ for (const [ctx, elements] of Object.entries(patch)) {
244
+ if (!isPlainObject(elements))
245
+ continue;
246
+ for (const [el, val] of Object.entries(elements)) {
247
+ applyLeaf(ctx, el, val);
248
+ }
249
+ }
250
+ }
251
+ }
252
+ catch (err) {
253
+ recordError(err, step, eventEl);
254
+ }
255
+ return;
256
+ }
257
+ // DEFAULT: merge the event payload into the target Information. The target is
258
+ // the single-object Information in the event's OWN slice if present, else the
259
+ // Information connected to this event via the Event Modeling edges (typically
260
+ // a following read slice). Merges into every connected Information (R11, R13).
261
+ const sameSliceInfo = step.information[0];
262
+ const targets = sameSliceInfo
263
+ ? [sameSliceInfo]
264
+ : (informationByEventName.get(event.name) ?? []);
265
+ if (targets.length === 0) {
266
+ log('apply', `default apply(${event.name}) → NO target Information (same-slice none, no event→information connection)`);
267
+ }
268
+ for (const targetInfo of targets) {
269
+ log('apply', `default apply(${event.name}) → merge into ${targetInfo.context || 'App'}.${targetInfo.name}`, event.payload);
270
+ mergeProjection(state, targetInfo.context || 'App', targetInfo.name, event.payload);
271
+ }
272
+ };
273
+ /** Leaf-replace write honouring the className keying + non-object shapes (R13). */
274
+ const applyLeaf = (ctxDisplay, elDisplay, value) => {
275
+ const ctxKey = toClassName(ctxDisplay);
276
+ const elKey = toClassName(elDisplay);
277
+ const ctxBucket = isPlainObject(state[ctxKey]) ? state[ctxKey] : {};
278
+ if (isPlainObject(value)) {
279
+ const existing = isPlainObject(ctxBucket[elKey]) ? ctxBucket[elKey] : {};
280
+ ctxBucket[elKey] = { ...existing, ...value }; // leaf-merge for objects
281
+ }
282
+ else {
283
+ ctxBucket[elKey] = clone(value); // lists/scalars: replace wholesale
284
+ }
285
+ state[ctxKey] = ctxBucket;
286
+ };
287
+ const recordError = (err, step, el) => {
288
+ const where = el ? `${el.type} ${el.name}` : `step ${step.stepIndex}`;
289
+ if (isCascadeBudgetExceeded(err)) {
290
+ log('error', `cascade-exceeded (${err.kind}) at ${where}: ${err.message}`);
291
+ errors.push({
292
+ kind: 'cascade-exceeded',
293
+ message: err.message,
294
+ stepIndex: step.stepIndex,
295
+ sliceId: step.slice.id,
296
+ elementId: el?.id,
297
+ elementName: el?.name,
298
+ cascadeKind: err.kind,
299
+ });
300
+ throw err; // cascade stops the whole step — rethrow to unwind the fold step
301
+ }
302
+ if (isRejectSignal(err)) {
303
+ log('error', `reject() at ${where}: ${err.reason || 'Command rejected.'}`);
304
+ errors.push({
305
+ kind: 'rejected',
306
+ message: err.reason || 'Command rejected.',
307
+ stepIndex: step.stepIndex,
308
+ sliceId: step.slice.id,
309
+ elementId: el?.id,
310
+ elementName: el?.name,
311
+ });
312
+ return;
313
+ }
314
+ const name = err instanceof Error ? err.name : 'Unknown';
315
+ const firstFrame = err instanceof Error && err.stack ? (err.stack.split('\n')[1]?.trim() ?? '') : '';
316
+ log('error', `threw at ${where}: [${name}] ${err instanceof Error ? err.message : String(err)}${firstFrame ? ` @ ${firstFrame}` : ''}`);
317
+ errors.push({
318
+ kind: 'threw',
319
+ message: err instanceof Error ? err.message : String(err),
320
+ stepIndex: step.stepIndex,
321
+ sliceId: step.slice.id,
322
+ elementId: el?.id,
323
+ elementName: el?.name,
324
+ });
325
+ };
326
+ /** Resolve the command element a produced command refers to, by name (+context). */
327
+ const resolveCommandElement = (command, fallbackStep) => {
328
+ // Prefer a command element in the current step, else anywhere in the chapter.
329
+ const inStep = fallbackStep.commands.find((c) => c.name === command.name);
330
+ if (inStep)
331
+ return inStep;
332
+ return chapter.elements.find((e) => e.type === 'command' && e.name === command.name &&
333
+ (!command.context || (e.context || 'App') === command.context)) ?? chapter.elements.find((e) => e.type === 'command' && e.name === command.name);
334
+ };
335
+ /** The step a command element lives in (for its slice events in the default). */
336
+ const stepForElement = (el, fallback) => {
337
+ if (!el)
338
+ return fallback;
339
+ return steps.find((s) => s.slice.id === el.sliceId) ?? fallback;
340
+ };
341
+ /** Run a command through decide (authored or default) → ordered events. */
342
+ const runCommand = (step, command) => {
343
+ const cmdEl = resolveCommandElement(command, step);
344
+ const targetStep = stepForElement(cmdEl, step);
345
+ const authored = handlerFor(cmdEl);
346
+ let decided;
347
+ if (authored) {
348
+ try {
349
+ const out = runHandler(getCompiled(authored, 'decide'), clock.surface(), [clone(command), frozenState()]);
350
+ decided = Array.isArray(out)
351
+ ? out.map((e) => {
352
+ const ev = e;
353
+ return { name: String(ev.name ?? ''), context: ev.context || command.context, payload: ev.payload ?? {} };
354
+ })
355
+ : [];
356
+ log('decide', `decide(${command.name}, state) → ${decided.length} event(s): ${decided.map((d) => d.name).join(', ') || '—'}`, command.payload);
357
+ }
358
+ catch (err) {
359
+ recordError(err, step, cmdEl);
360
+ return; // reject/throw: no events, no state change for this command
361
+ }
362
+ for (const ev of decided)
363
+ emitEvent(targetStep, ev);
364
+ }
365
+ else {
366
+ // DEFAULT decide: emit the command's connected event(s) — which may live in
367
+ // a following event-only slice — each carrying the command payload (R11).
368
+ // Emit each event in the STEP that contains its event element, so authored
369
+ // apply / default-merge resolve against the right slice's Information.
370
+ const eventEls = cmdEl ? (eventsByCommandId.get(cmdEl.id) ?? []) : [];
371
+ if (eventEls.length > 0) {
372
+ log('decide', `default decide(${command.name}) → connected event(s): ${eventEls.map((e) => e.name).join(', ')}`, command.payload);
373
+ for (const evtEl of eventEls) {
374
+ const evtStep = stepForElement(evtEl, targetStep);
375
+ emitEvent(evtStep, {
376
+ name: evtEl.name,
377
+ context: evtEl.context || command.context,
378
+ payload: clone(command.payload) ?? {},
379
+ });
380
+ }
381
+ }
382
+ else {
383
+ // Fallback: command's own slice events (M1 single-slice behaviour).
384
+ log('decide', `default decide(${command.name}) → slice event(s): ${targetStep.events.map((e) => e.name).join(', ') || '—'}`, command.payload);
385
+ for (const ev of defaultDecide(targetStep, command))
386
+ emitEvent(targetStep, ev);
387
+ }
388
+ }
389
+ };
390
+ /** Build a command object for an element, with the storage that drives it. */
391
+ const commandFromElement = (cmdEl, storage) => ({
392
+ name: cmdEl.name,
393
+ context: cmdEl.context || 'App',
394
+ payload: storage ? clone(storage) : {},
395
+ });
396
+ /**
397
+ * Fire automation event-reactions for a just-emitted event (R10 react).
398
+ * Reacting automations are resolved via connection rules (reactionsByEventName).
399
+ * Each authored `react(event, state)` returns commands which are RECORDED as
400
+ * PENDING (not run immediately): they fire when the playhead reaches the target
401
+ * command's slice (draft §4.6), mirroring the UI captured-input → command flow.
402
+ */
403
+ const fireReactions = (step, event) => {
404
+ const reactors = reactionsByEventName.get(event.name);
405
+ if (!reactors || reactors.length === 0)
406
+ return;
407
+ for (const autoEl of reactors) {
408
+ const authored = handlerFor(autoEl);
409
+ if (!authored)
410
+ continue; // default automation reaction is a no-op (R11)
411
+ try {
412
+ const out = runHandler(getCompiled(authored, 'react'), clock.surface(), [clone(event), frozenState()]);
413
+ const cmds = Array.isArray(out) ? out : [];
414
+ log('react', `react(${event.name}, state) by ${autoEl.name} → ${cmds.length} command(s): ${cmds.map((c) => c?.name).join(', ') || '—'}`);
415
+ if (Array.isArray(out)) {
416
+ for (const c of out) {
417
+ const cc = c;
418
+ recordPendingCommand(autoEl, {
419
+ name: String(cc.name ?? ''),
420
+ context: cc.context || (autoEl.context || 'App'),
421
+ payload: cc.payload ?? {},
422
+ }, event.name);
423
+ }
424
+ }
425
+ }
426
+ catch (err) {
427
+ recordError(err, step, autoEl);
428
+ }
429
+ }
430
+ };
431
+ /** Record an automation-dispatched command as pending (fires at its slice). */
432
+ const recordPendingCommand = (autoEl, command, triggeredByEventName) => {
433
+ const target = chapter.elements.find((e) => e.type === 'command' && e.name === command.name &&
434
+ (!command.context || (e.context || 'App') === command.context)) ?? chapter.elements.find((e) => e.type === 'command' && e.name === command.name);
435
+ log('pending', `defer command ${command.context}.${command.name} from ${autoEl.name}${triggeredByEventName ? ` (on ${triggeredByEventName})` : ' (job)'} — ${target ? 'target resolved' : 'UNRESOLVED target'}`, command.payload);
436
+ pendingCommands.push({
437
+ commandElementId: target?.id,
438
+ name: command.name,
439
+ context: command.context,
440
+ payload: clone(command.payload) ?? {},
441
+ sourceAutomationId: autoEl.id,
442
+ sourceAutomationName: autoEl.name,
443
+ triggeredByEventName,
444
+ });
445
+ };
446
+ // Connection-aware storage routing (UI may drive a command in a later slice).
447
+ const storageByCommandId = new Map();
448
+ for (let s = 0; s <= upto; s++) {
449
+ const uiStep = steps[s];
450
+ if (!uiStep.uiElement)
451
+ continue;
452
+ const storage = storageForStep(s);
453
+ if (storage === undefined)
454
+ continue;
455
+ for (const cmd of getCommandsDrivenByUi(chapter, uiStep.uiElement)) {
456
+ storageByCommandId.set(cmd.id, storage);
457
+ }
458
+ }
459
+ // ── Fold steps 0..upto ─────────────────────────────────────────────────────
460
+ for (let s = 0; s <= upto; s++) {
461
+ const step = steps[s];
462
+ try {
463
+ log('flow', `step ${s}/${upto} — slice "${step.slice.label}"${step.uiElement ? ` ui=${step.uiElement.name}` : ''}${step.commands.length ? ` cmd=${step.commands.map((c) => c.name).join(',')}` : ''}${step.events.length ? ` evt=${step.events.map((e) => e.name).join(',')}` : ''}${step.automationElement ? ` auto=${step.automationElement.name}` : ''}`);
464
+ // Expose this step's captured input via state.$input BEFORE any handler on
465
+ // this step runs, so interact/decide/apply (and the end-of-fold read) can
466
+ // query it. Only UI steps carry input; others leave $input unchanged.
467
+ if (step.uiElement) {
468
+ const stepStorage = storageForStep(s);
469
+ if (stepStorage !== undefined) {
470
+ inputByStep[s] = stepStorage;
471
+ refreshInput();
472
+ log('input', `$input ← step ${s} storage (${step.uiElement.name})`, stepStorage);
473
+ }
474
+ }
475
+ // 1. UI step with an authored interact → commands (authored only; the
476
+ // default UI path is handled by storage routing feeding the command).
477
+ let uiInteractRan = false;
478
+ if (step.uiElement) {
479
+ const authored = handlerFor(step.uiElement);
480
+ if (authored) {
481
+ uiInteractRan = true;
482
+ const storage = storageForStep(s) ?? {};
483
+ try {
484
+ const out = runHandler(getCompiled(authored, 'interact'), clock.surface(), [clone(storage), frozenState()]);
485
+ const cmds = Array.isArray(out) ? out : [];
486
+ log('interact', `interact(storage, state) on ${step.uiElement.name} → ${cmds.length} command(s): ${cmds.map((c) => c?.name).join(', ') || '—'}`, storage);
487
+ if (Array.isArray(out)) {
488
+ for (const c of out) {
489
+ const cc = c;
490
+ runCommand(step, {
491
+ name: String(cc.name ?? ''),
492
+ context: cc.context || (step.uiElement.context || 'App'),
493
+ payload: cc.payload ?? {},
494
+ });
495
+ }
496
+ }
497
+ }
498
+ catch (err) {
499
+ recordError(err, step, step.uiElement);
500
+ }
501
+ }
502
+ }
503
+ // 2. Commands in the slice. Each command fires from exactly one source,
504
+ // checked in order: (a) a PENDING command dispatched earlier by an
505
+ // automation whose target is this command; (b) UI storage routed to it;
506
+ // (c) the M1 default. Skipped entirely when an authored UI interact ran
507
+ // (that interact is the command source for this step).
508
+ if (!uiInteractRan && step.commands.length > 0) {
509
+ for (const cmdEl of step.commands) {
510
+ // (a) consume pending commands targeting this command element.
511
+ let firedFromPending = false;
512
+ for (let pi = 0; pi < pendingCommands.length; pi++) {
513
+ if (firedPending.has(pi))
514
+ continue;
515
+ const pc = pendingCommands[pi];
516
+ if (pc.commandElementId !== cmdEl.id)
517
+ continue;
518
+ firedPending.add(pi);
519
+ firedFromPending = true;
520
+ log('pending', `fire deferred command ${pc.context}.${pc.name} at slice "${step.slice.label}" (from ${pc.sourceAutomationName})`, pc.payload);
521
+ runCommand(step, { name: pc.name, context: pc.context, payload: clone(pc.payload) });
522
+ }
523
+ if (firedFromPending)
524
+ continue;
525
+ // (b)/(c) storage-routed or default.
526
+ const storage = storageByCommandId.get(cmdEl.id);
527
+ runCommand(step, commandFromElement(cmdEl, storage));
528
+ }
529
+ }
530
+ else if (!uiInteractRan && step.events.length > 0 && !step.uiElement) {
531
+ // 3. Hand-drawn events with no command driver: emit directly (empty payload),
532
+ // applying authored/default apply. Preserves M1 behaviour.
533
+ for (const evtEl of step.events) {
534
+ emitEvent(step, { name: evtEl.name, context: evtEl.context || 'App', payload: {} });
535
+ }
536
+ }
537
+ // 4. Automation (job) process(state) → commands, when the playhead reaches it.
538
+ // An automation is a JOB only if it is NOT an event-reaction target
539
+ // (reaction automations fire via fireReactions when their event occurs).
540
+ if (step.automationElement) {
541
+ const authored = handlerFor(step.automationElement);
542
+ const isReaction = reactionAutomationIds.has(step.automationElement.id);
543
+ if (authored && !isReaction) {
544
+ try {
545
+ const out = runHandler(getCompiled(authored, 'process'), clock.surface(), [frozenState()]);
546
+ const cmds = Array.isArray(out) ? out : [];
547
+ log('process', `process(state) on ${step.automationElement.name} → ${cmds.length} command(s): ${cmds.map((c) => c?.name).join(', ') || '—'}`);
548
+ if (Array.isArray(out)) {
549
+ for (const c of out) {
550
+ const cc = c;
551
+ // Deferred like a reaction: the job's command fires when the
552
+ // playhead reaches its (downstream) slice. Surfaced as pending.
553
+ recordPendingCommand(step.automationElement, {
554
+ name: String(cc.name ?? ''),
555
+ context: cc.context || (step.automationElement.context || 'App'),
556
+ payload: cc.payload ?? {},
557
+ });
558
+ }
559
+ }
560
+ }
561
+ catch (err) {
562
+ recordError(err, step, step.automationElement);
563
+ }
564
+ }
565
+ }
566
+ // 5. Read-step surfacing: ensure seeded Information buckets appear (M1 parity).
567
+ if (step.events.length === 0 && step.commands.length === 0 && step.information.length > 0) {
568
+ for (const info of step.information) {
569
+ const ctxKey = toClassName(info.context || 'App');
570
+ const elKey = toClassName(info.name);
571
+ const ctx = isPlainObject(state[ctxKey]) ? state[ctxKey] : {};
572
+ if (!(elKey in ctx)) {
573
+ ctx[elKey] = ctx[elKey] ?? {};
574
+ state[ctxKey] = ctx;
575
+ }
576
+ }
577
+ }
578
+ }
579
+ catch (err) {
580
+ // A cascade-exceeded stops THIS step; record it (it may have been thrown
581
+ // from emitEvent's budget check without passing through recordError) and
582
+ // continue so the playhead settles with last-good state for earlier steps.
583
+ if (isCascadeBudgetExceeded(err)) {
584
+ if (!errors.some((e) => e.kind === 'cascade-exceeded' && e.stepIndex === step.stepIndex)) {
585
+ errors.push({
586
+ kind: 'cascade-exceeded',
587
+ message: err.message,
588
+ stepIndex: step.stepIndex,
589
+ sliceId: step.slice.id,
590
+ cascadeKind: err.kind,
591
+ });
592
+ }
593
+ }
594
+ else {
595
+ recordError(err, step);
596
+ }
597
+ }
598
+ }
599
+ const unfiredPending = pendingCommands.filter((_pc, i) => !firedPending.has(i));
600
+ const readViews = computeReadViews(upto);
601
+ log('flow', `fold done — events=${state.$events.length}, errors=${errors.length}, pending=${unfiredPending.length}, readViews=${readViews.length}, clock=${clock.now()}`);
602
+ return {
603
+ events: state.$events,
604
+ state,
605
+ errors,
606
+ readViews,
607
+ pendingCommands: unfiredPending,
608
+ clockISO: clock.now(),
609
+ };
610
+ }
611
+ /** Convenience: derive steps + run the fold in one call (used by the worker). */
612
+ export function foldChapter(chapter, scenario, playheadIndex, handlers, liveStorage, cascadeLimits, logSource) {
613
+ if (!chapter) {
614
+ const empty = seedState(scenario, new DeterministicClock(scenario.clock).now());
615
+ return { events: empty.$events, state: empty, errors: [], readViews: [] };
616
+ }
617
+ const steps = deriveRuntimeSteps(chapter);
618
+ return runFold({ chapter, steps, scenario, playheadIndex, handlers, liveStorage, cascadeLimits, logSource });
619
+ }
620
+ //# sourceMappingURL=runtimeFold.js.map