@sharpee/story-loader 3.0.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.
package/runtime.js ADDED
@@ -0,0 +1,1249 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChordRuntime = exports.ChordBehaviorTrait = exports.STRATEGY_SELECTOR = void 0;
4
+ const world_model_1 = require("@sharpee/world-model");
5
+ const errors_1 = require("./errors");
6
+ const state_keys_1 = require("./state-keys");
7
+ const text_1 = require("./text");
8
+ const hatch_context_1 = require("./hatch-context");
9
+ const event_contract_1 = require("./event-contract");
10
+ /**
11
+ * Chord strategy adverb → phrase-algebra Choice selector (ADR-196).
12
+ * The Z5 table (ADR-211 Decision 4): adverbs mirror the selectors 1:1;
13
+ * `ordered`/`once` are retired at parse time and never reach here.
14
+ * Exported as the single implementation (ratchet Z5) — the loader's Z2
15
+ * snippet compile maps the same adverbs onto `SnippetEntry.selector`.
16
+ */
17
+ exports.STRATEGY_SELECTOR = {
18
+ randomly: 'random',
19
+ cycling: 'cycling',
20
+ stopping: 'stopping',
21
+ sticky: 'sticky',
22
+ 'first-time': 'firstTime',
23
+ };
24
+ /** Marker trait carried by entities with compiled `on` clauses. */
25
+ class ChordBehaviorTrait {
26
+ static type = 'chord.behavior';
27
+ type = ChordBehaviorTrait.type;
28
+ }
29
+ exports.ChordBehaviorTrait = ChordBehaviorTrait;
30
+ class ChordRuntime {
31
+ ir;
32
+ host;
33
+ evaluator;
34
+ eventSeq = 0;
35
+ /** Declared score identities (Phase B): name → worth. */
36
+ scoreWorth = new Map();
37
+ constructor(ir, host, evaluator) {
38
+ this.ir = ir;
39
+ this.host = host;
40
+ this.evaluator = evaluator;
41
+ for (const score of ir.scores)
42
+ this.scoreWorth.set(score.name, score.worth);
43
+ }
44
+ // ------------------------------------------------------------------ bind
45
+ /** Register on/after clauses, event clauses, and derived-property chains. */
46
+ bind(world) {
47
+ // The interceptor registry is keyed (traitType, actionId) — a second
48
+ // registration for the same action would REPLACE the first, silently
49
+ // disabling earlier entities' clauses. Group clauses by action and
50
+ // register one dispatching interceptor per action that routes by the
51
+ // action's target entity.
52
+ const byAction = new Map();
53
+ for (const entity of this.ir.entities) {
54
+ entity.onClauses.forEach((clause, clauseIndex) => {
55
+ // Entity every-turn clauses are scheduler daemons, not interceptors.
56
+ if (clause.binding === 'every-turn')
57
+ return;
58
+ // Event clauses (`after entering it`) bind to the event stream per
59
+ // the selector contract — the ownership package's replacement for
60
+ // floating `when` rules.
61
+ if (event_contract_1.EVENT_TRIGGERS[clause.action]) {
62
+ this.bindEventClause(world, entity, clause, clauseIndex);
63
+ return;
64
+ }
65
+ this.prepareOnClauseTarget(world, entity, clause);
66
+ const list = byAction.get(clause.action) ?? [];
67
+ list.push({ entity, clause });
68
+ byAction.set(clause.action, list);
69
+ });
70
+ }
71
+ for (const [action, clauses] of byAction) {
72
+ world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(clauses));
73
+ }
74
+ // Phase B: `define trait` clauses register per TRAIT TYPE — capability
75
+ // behaviors for dispatch verbs, interceptors for standard-semantics
76
+ // actions (§5.4 routing recorded on the IR by the analyzer).
77
+ for (const trait of this.ir.traits) {
78
+ const traitType = state_keys_1.CHORD_TRAIT_PREFIX + trait.name;
79
+ for (const clause of trait.onClauses) {
80
+ if (clause.binding === 'every-turn')
81
+ continue; // scheduler phase (plan phase 5)
82
+ if (clause.binding === 'role') {
83
+ throw new errors_1.LoadError(`Role-bound trait clauses (\`on ${clause.action} anything as the ${clause.role}\`) are not wired yet — the standard-action role path is post-Zoo scope.`, clause.span);
84
+ }
85
+ if (clause.routing === 'capability') {
86
+ world.registerCapabilityBehavior(traitType, `chord.action.${clause.action}`, this.buildCapabilityBehavior(trait.name, clause));
87
+ }
88
+ else {
89
+ world.registerActionInterceptor(traitType, `if.action.${clause.action}`, this.buildTraitInterceptor(clause));
90
+ }
91
+ }
92
+ }
93
+ // Derived properties (`dark while`, `is blocked while`) — recompute
94
+ // when possession, location, or openable/switchable state changes.
95
+ if (this.derivedDarkRooms().length > 0 || this.hasConditionalBlockedExits()) {
96
+ for (const trigger of [
97
+ 'if.event.taken',
98
+ 'if.event.dropped',
99
+ 'if.event.worn',
100
+ 'if.event.removed',
101
+ 'if.event.put_on',
102
+ 'if.event.put_in',
103
+ 'if.event.actor_moved',
104
+ 'if.event.opened',
105
+ 'if.event.closed',
106
+ 'if.event.switched_on',
107
+ 'if.event.switched_off',
108
+ ]) {
109
+ world.chainEvent(trigger, (_event, w) => {
110
+ this.recomputeDerived(w);
111
+ return null;
112
+ }, { key: `chord.derived.${trigger}`, priority: 100 });
113
+ }
114
+ }
115
+ }
116
+ // ---------------------------------------------------------- event clauses
117
+ /**
118
+ * Bind an event clause (`after entering it` on a room) to its trigger
119
+ * event per the selector contract — the ownership package's replacement
120
+ * for floating `when` rules: the same firing semantics, owned by the
121
+ * entity the event is about.
122
+ */
123
+ bindEventClause(world, entity, clause, clauseIndex) {
124
+ const trigger = event_contract_1.EVENT_TRIGGERS[clause.action];
125
+ const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
126
+ world.chainEvent(trigger, (event, w) => this.fireEventClause(entity, clause, key, event, w), { key });
127
+ }
128
+ /** Test/debug entry: run every event clause bound to this event type. */
129
+ fireEventClauses(world, event) {
130
+ const out = [];
131
+ for (const entity of this.ir.entities) {
132
+ entity.onClauses.forEach((clause, clauseIndex) => {
133
+ if (clause.binding === 'every-turn' || event_contract_1.EVENT_TRIGGERS[clause.action] !== event.type)
134
+ return;
135
+ const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
136
+ const produced = this.fireEventClause(entity, clause, key, event, world);
137
+ if (produced)
138
+ out.push(...produced);
139
+ });
140
+ }
141
+ return out;
142
+ }
143
+ fireEventClause(entity, clause, key, event, world) {
144
+ // The clause is about its owner: `after entering it` fires when the
145
+ // movement's destination IS the owner — read through the AC-9 payload
146
+ // guard, never a blind cast (the stdlib event is a foreign surface).
147
+ if (clause.action === 'entering' && (0, event_contract_1.enteringDestination)(event.data) !== this.host.entityId(entity.id))
148
+ return null;
149
+ const ctx = { world, it: entity.id };
150
+ if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
151
+ return null;
152
+ const occKey = state_keys_1.CHORD_OCCURRENCE_PREFIX + key;
153
+ const occurrence = (world.getStateValue(occKey) ?? 0) + 1;
154
+ if (clause.once && occurrence > 1)
155
+ return null; // `, once` — one lifetime firing (D5)
156
+ world.setStateValue(occKey, occurrence);
157
+ ctx.occurrence = occurrence;
158
+ ctx.decisions = this.snapshotDecisions(clause.body, ctx);
159
+ return this.execStatements(clause.body, ctx);
160
+ }
161
+ // ------------------------------------------------------------ on-clauses
162
+ /** Mark the clause's target entity so interceptor resolution finds it. */
163
+ prepareOnClauseTarget(world, entity, clause) {
164
+ const worldId = this.host.entityId(entity.id);
165
+ if (!worldId)
166
+ throw new errors_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, clause.span);
167
+ const target = world.getEntity(worldId);
168
+ if (!target)
169
+ throw new errors_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, clause.span);
170
+ if (!target.has(ChordBehaviorTrait.type)) {
171
+ target.add(new ChordBehaviorTrait());
172
+ }
173
+ // `on reading it` targets must satisfy the reading action's trait gate.
174
+ if (clause.action === 'reading' && !target.has(world_model_1.TraitType.READABLE)) {
175
+ target.add(new world_model_1.ReadableTrait({ text: '' }));
176
+ }
177
+ }
178
+ /**
179
+ * One interceptor per action: each hook forwards to the clause whose
180
+ * entity is the action's target (per-clause interceptors keep their own
181
+ * occurrence keys and decision snapshots).
182
+ */
183
+ buildDispatchingInterceptor(clauses) {
184
+ const runtime = this;
185
+ const arms = clauses.map(({ entity, clause }) => ({
186
+ entity,
187
+ interceptor: this.buildInterceptor(entity, clause),
188
+ }));
189
+ const armFor = (target) => arms.find((a) => runtime.host.entityId(a.entity.id) === target.id)?.interceptor;
190
+ return {
191
+ preValidate(target, world, actorId, data) {
192
+ return armFor(target)?.preValidate?.(target, world, actorId, data) ?? null;
193
+ },
194
+ postValidate(target, world, actorId, data) {
195
+ return armFor(target)?.postValidate?.(target, world, actorId, data) ?? null;
196
+ },
197
+ postExecute(target, world, actorId, data) {
198
+ armFor(target)?.postExecute?.(target, world, actorId, data);
199
+ },
200
+ postReport(target, world, actorId, data) {
201
+ return armFor(target)?.postReport?.(target, world, actorId, data) ?? {};
202
+ },
203
+ };
204
+ }
205
+ /**
206
+ * Compile one `on`/`after` clause to an ActionInterceptor via the §5.4
207
+ * partition: leading refusals → preValidate (`on` only — `after` reacts
208
+ * and cannot refuse, ratchet D3); mutations → postExecute; phrase/emit/
209
+ * win/lose → postReport. An `on` clause's first phrase OVERRIDES the
210
+ * primary message; an `after` clause's phrases APPEND (D3).
211
+ */
212
+ buildInterceptor(entity, clause) {
213
+ const runtime = this;
214
+ const ownWorldId = () => runtime.host.entityId(entity.id);
215
+ const skipped = (data) => data.chordSkip === true;
216
+ return {
217
+ preValidate(target, world) {
218
+ if (target.id !== ownWorldId() || clause.clauseKind === 'after')
219
+ return null;
220
+ const ctx = { world, it: entity.id };
221
+ const refusal = runtime.findRefusal(clause.body, ctx);
222
+ return refusal ? { valid: false, error: refusal } : null;
223
+ },
224
+ postValidate(target, world, _actorId, data) {
225
+ if (target.id !== ownWorldId())
226
+ return null;
227
+ const ctx = { world, it: entity.id };
228
+ if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
229
+ data.chordSkip = true; // `while <cond>` gate — clause sits out this firing
230
+ return null;
231
+ }
232
+ const key = state_keys_1.CHORD_OCCURRENCE_PREFIX + `on.${entity.id}.${clause.action}`;
233
+ const occurrence = (world.getStateValue(key) ?? 0) + 1;
234
+ if (clause.once && occurrence > 1) {
235
+ data.chordSkip = true; // `, once` — one lifetime firing (D5)
236
+ return null;
237
+ }
238
+ world.setStateValue(key, occurrence);
239
+ ctx.occurrence = occurrence;
240
+ data.chordOccurrence = occurrence;
241
+ data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
242
+ return null;
243
+ },
244
+ postExecute(target, world, _actorId, data) {
245
+ if (target.id !== ownWorldId() || skipped(data))
246
+ return;
247
+ const ctx = runtime.restoreCtx(world, entity.id, data);
248
+ runtime.execStatements(clause.body, ctx, 'mutations');
249
+ },
250
+ postReport(target, world, _actorId, data) {
251
+ if (target.id !== ownWorldId() || skipped(data))
252
+ return {};
253
+ const ctx = runtime.restoreCtx(world, entity.id, data);
254
+ const reports = runtime.execStatements(clause.body, ctx, 'reports');
255
+ const result = {};
256
+ const emit = [];
257
+ for (const event of reports) {
258
+ const payload = (event.data ?? {});
259
+ if (clause.clauseKind === 'on' && event.type === 'chord.phrase' && !result.override) {
260
+ result.override = {
261
+ messageId: String(payload.messageId),
262
+ params: payload.params ?? {},
263
+ };
264
+ }
265
+ else {
266
+ emit.push({ type: event.type, payload });
267
+ }
268
+ }
269
+ if (emit.length)
270
+ result.emit = emit;
271
+ return result;
272
+ },
273
+ };
274
+ }
275
+ restoreCtx(world, itIrId, data) {
276
+ return {
277
+ world,
278
+ it: itIrId,
279
+ occurrence: data.chordOccurrence,
280
+ decisions: data.chordDecisions,
281
+ };
282
+ }
283
+ // ------------------------------------------- dispatch verbs (Phase B, §5.4)
284
+ /** Write a `define trait` data field on the entity's chord trait instance. */
285
+ writeChordTraitField(world, worldId, field, value, span) {
286
+ const entity = world.getEntity(worldId);
287
+ for (const trait of entity?.traits.values() ?? []) {
288
+ if (!trait.type.startsWith(state_keys_1.CHORD_TRAIT_PREFIX))
289
+ continue;
290
+ const record = trait;
291
+ if (field in record) {
292
+ record[field] = typeof value === 'boolean' ? String(value) : value;
293
+ return;
294
+ }
295
+ }
296
+ throw new errors_1.LoadError(`No trait on this entity carries the field \`${field}\`.`, span);
297
+ }
298
+ /**
299
+ * A `define trait` clause on a dispatch verb → CapabilityBehavior
300
+ * (§5.4's second half): refusal scan in validate (with the occurrence
301
+ * bump and decision snapshot stashed in sharedData), mutations in
302
+ * execute, phrase/emit/win/lose in report.
303
+ */
304
+ buildCapabilityBehavior(traitName, clause) {
305
+ const runtime = this;
306
+ const ctxOf = (entity, world, actorId, data) => ({
307
+ world,
308
+ it: runtime.host.irIdOf(entity.id),
309
+ slots: { ...data.chordSlots, actor: actorId },
310
+ occurrence: data.chordOccurrence,
311
+ decisions: data.chordDecisions,
312
+ });
313
+ return {
314
+ validate(entity, world, actorId, data) {
315
+ const ctx = ctxOf(entity, world, actorId, data);
316
+ const refusal = runtime.findRefusal(clause.body, ctx);
317
+ if (refusal)
318
+ return { valid: false, error: refusal };
319
+ const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${traitName}.${clause.action}.${runtime.host.irIdOf(entity.id)}`;
320
+ const occurrence = (world.getStateValue(key) ?? 0) + 1;
321
+ world.setStateValue(key, occurrence);
322
+ ctx.occurrence = occurrence;
323
+ data.chordOccurrence = occurrence;
324
+ data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
325
+ return { valid: true };
326
+ },
327
+ execute(entity, world, actorId, data) {
328
+ runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'mutations');
329
+ },
330
+ report(entity, world, actorId, data) {
331
+ const events = runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'reports');
332
+ return events.map((e) => ({ type: e.type, payload: (e.data ?? {}) }));
333
+ },
334
+ blocked(entity, world, actorId, error, data) {
335
+ const event = runtime.phraseEvent(error, ctxOf(entity, world, actorId, data));
336
+ return [{ type: event.type, payload: (event.data ?? {}) }];
337
+ },
338
+ };
339
+ }
340
+ /**
341
+ * A `define trait` clause on a standard-semantics action → one
342
+ * ActionInterceptor registered under the trait type (ADR-118 resolves it
343
+ * for every entity carrying the trait).
344
+ */
345
+ buildTraitInterceptor(clause) {
346
+ const runtime = this;
347
+ const itOf = (target) => runtime.host.irIdOf(target.id) ?? target.id;
348
+ return {
349
+ preValidate(target, world) {
350
+ const refusal = runtime.findRefusal(clause.body, { world, it: itOf(target) });
351
+ return refusal ? { valid: false, error: refusal } : null;
352
+ },
353
+ postValidate(target, world, _actorId, data) {
354
+ const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${clause.action}.${itOf(target)}`;
355
+ const occurrence = (world.getStateValue(key) ?? 0) + 1;
356
+ world.setStateValue(key, occurrence);
357
+ const ctx = { world, it: itOf(target), occurrence };
358
+ data.chordOccurrence = occurrence;
359
+ data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
360
+ return null;
361
+ },
362
+ postExecute(target, world, _actorId, data) {
363
+ runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'mutations');
364
+ },
365
+ postReport(target, world, _actorId, data) {
366
+ const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'reports');
367
+ const result = {};
368
+ const emit = [];
369
+ for (const event of reports) {
370
+ const payload = (event.data ?? {});
371
+ if (event.type === 'chord.phrase' && !result.override) {
372
+ result.override = { messageId: String(payload.messageId), params: payload.params ?? {} };
373
+ }
374
+ else {
375
+ emit.push({ type: event.type, payload });
376
+ }
377
+ }
378
+ if (emit.length)
379
+ result.emit = emit;
380
+ return result;
381
+ },
382
+ };
383
+ }
384
+ /**
385
+ * `define action` → a four-phase dispatch action (structurally typed —
386
+ * `Story.getCustomActions()` is untyped by design): the refusal ladder
387
+ * runs in validate, the matched CapabilityBehavior carries the phases,
388
+ * `otherwise refuse` is the dispatch-miss, and `when <player> <verbs>`
389
+ * rules fire in report.
390
+ */
391
+ buildDispatchActions() {
392
+ return this.ir.actions.map((def) => this.buildDispatchAction(def));
393
+ }
394
+ buildDispatchAction(def) {
395
+ const runtime = this;
396
+ const actionId = `chord.action.${def.name}`;
397
+ const primarySlot = def.patterns.flatMap((p) => p.parts).find((part) => part.kind === 'slot')?.word;
398
+ return {
399
+ id: actionId,
400
+ group: 'interaction',
401
+ validate(context) {
402
+ const entity = context.command.directObject?.entity;
403
+ for (const refusal of def.refusals) {
404
+ if (refusal.kind === 'without' && !entity) {
405
+ return { valid: false, error: refusal.phraseKey };
406
+ }
407
+ if (refusal.kind === 'when' && entity) {
408
+ const ctx = {
409
+ world: context.world,
410
+ slots: runtime.slotBindings(primarySlot, entity, context.player),
411
+ };
412
+ if (runtime.evaluator.evalCondition(refusal.condition, ctx)) {
413
+ return { valid: false, error: refusal.phraseKey };
414
+ }
415
+ }
416
+ }
417
+ if (!entity)
418
+ return { valid: false, error: def.otherwise ?? 'cant' };
419
+ const slots = runtime.slotBindings(primarySlot, entity, context.player);
420
+ // Action-level requirements (`<subject> must …: <key>`, D6) run
421
+ // after the refusal ladder, before dispatch — the action's own
422
+ // gate, evaluated in the slots context (wired with the each
423
+ // package's zoo-chain fixes, 2026-07-12).
424
+ for (const must of def.musts) {
425
+ if (!runtime.evaluator.evalCondition(must.condition, { world: context.world, slots })) {
426
+ return { valid: false, error: must.phraseKey };
427
+ }
428
+ }
429
+ // Dispatch: the first trait on the target with a behavior bound for
430
+ // this action claims it (per-world binding map, ADR-090/207).
431
+ // Instance-type lookup: ChordDataTrait types are per-instance, so
432
+ // the constructor-static path (getBehaviorForCapability) can't see
433
+ // them.
434
+ let behavior;
435
+ for (const trait of entity.traits.values()) {
436
+ behavior = context.world.getBehaviorBinding(trait.type, actionId)?.behavior;
437
+ if (behavior)
438
+ break;
439
+ }
440
+ // A behavior host is optional when the action carries its own body
441
+ // (§5.4: the body IS the action's semantics — photographing has no
442
+ // per-trait behavior by design). No behavior AND no body = the
443
+ // dispatch miss, exactly as before.
444
+ if (!behavior && def.body.length === 0)
445
+ return { valid: false, error: def.otherwise ?? 'cant' };
446
+ const capShared = { chordSlots: slots };
447
+ if (behavior) {
448
+ const result = behavior.validate(entity, context.world, context.player.id, capShared);
449
+ if (!result.valid)
450
+ return { valid: false, error: result.error };
451
+ }
452
+ if (def.body.length) {
453
+ // The body's own validate partition (leading refusals/musts) and
454
+ // the pre-mutation decision snapshot (§5.4).
455
+ const bodyCtx = { world: context.world, slots };
456
+ const refusal = runtime.findRefusal(def.body, bodyCtx);
457
+ if (refusal)
458
+ return { valid: false, error: refusal };
459
+ context.sharedData.chordBodyDecisions = runtime.snapshotDecisions(def.body, bodyCtx);
460
+ }
461
+ context.sharedData.capEntity = entity;
462
+ context.sharedData.capBehavior = behavior;
463
+ context.sharedData.capShared = capShared;
464
+ return { valid: true };
465
+ },
466
+ execute(context) {
467
+ const entity = context.sharedData.capEntity;
468
+ const behavior = context.sharedData.capBehavior;
469
+ if (entity && behavior) {
470
+ behavior.execute(entity, context.world, context.player.id, context.sharedData.capShared);
471
+ }
472
+ if (entity && def.body.length) {
473
+ runtime.execStatements(def.body, runtime.actionBodyCtx(primarySlot, entity, context), 'mutations');
474
+ }
475
+ },
476
+ report(context) {
477
+ const entity = context.sharedData.capEntity;
478
+ const behavior = context.sharedData.capBehavior;
479
+ if (!entity)
480
+ return [];
481
+ const events = [];
482
+ if (behavior) {
483
+ const effects = behavior.report(entity, context.world, context.player.id, context.sharedData.capShared);
484
+ events.push(...effects.map((e) => context.event(e.type, e.payload)));
485
+ }
486
+ if (def.body.length) {
487
+ events.push(...runtime.execStatements(def.body, runtime.actionBodyCtx(primarySlot, entity, context), 'reports'));
488
+ }
489
+ events.push(...runtime.fireAfterClauses(def.name, entity, context.world));
490
+ return events;
491
+ },
492
+ blocked(context, result) {
493
+ const key = result.error ?? def.otherwise ?? 'cant';
494
+ const event = runtime.phraseEvent(key, { world: context.world });
495
+ return [context.event(event.type, (event.data ?? {}))];
496
+ },
497
+ };
498
+ }
499
+ slotBindings(primarySlot, entity, player) {
500
+ const slots = { actor: player.id };
501
+ if (primarySlot)
502
+ slots[primarySlot] = entity.id;
503
+ return slots;
504
+ }
505
+ /**
506
+ * Execution context for a `define action` body (§5.4): grammar slots
507
+ * bound, no `it` (action bodies have no owner), decision snapshot from
508
+ * validate carried through sharedData.
509
+ */
510
+ actionBodyCtx(primarySlot, entity, context) {
511
+ return {
512
+ world: context.world,
513
+ slots: this.slotBindings(primarySlot, entity, context.player),
514
+ decisions: context.sharedData.chordBodyDecisions,
515
+ };
516
+ }
517
+ // -------------------------------------------- scheduler constructs (Phase B)
518
+ /**
519
+ * Build the story's scheduler daemons (`once` / `every N turns` /
520
+ * `define sequence` / every-turn trait clauses). ALL progression state is
521
+ * namespaced world state — save/restore/undo cover it with no
522
+ * getRunnerState plumbing (design.md §6). Registered by
523
+ * ChordStory.onEngineReady; exposed for direct unit driving.
524
+ */
525
+ buildSchedulerDaemons() {
526
+ const daemons = [];
527
+ for (const sequence of this.ir.sequences) {
528
+ // Steps arm in order: `at turn N` on the wall clock, `N turns later`
529
+ // relative to the PREVIOUS step's firing turn, `when <owner> becomes
530
+ // <state>` on a state anchor (ratchet D10). Pointer and last-fired
531
+ // turn live in world state — save/restore covers progression.
532
+ const slug = sequence.name.replace(/\s+/g, '-');
533
+ const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
534
+ const firedKey = `${key}.turn`;
535
+ const stepReady = (step, world, turn) => {
536
+ switch (step.timing) {
537
+ case 'at-turn':
538
+ return turn >= step.turns;
539
+ case 'later': {
540
+ const lastFired = world.getStateValue(firedKey) ?? 0;
541
+ return turn >= lastFired + step.turns;
542
+ }
543
+ case 'becomes': {
544
+ if (!step.anchor)
545
+ return false;
546
+ if (step.anchor.owner === 'story') {
547
+ return world.getStateValue(state_keys_1.CHORD_STORY_STATE_KEY) === step.anchor.state;
548
+ }
549
+ return world.getStateValue(state_keys_1.CHORD_STATE_PREFIX + step.anchor.owner) === step.anchor.state;
550
+ }
551
+ }
552
+ };
553
+ daemons.push({
554
+ id: `chord.sequence.${slug}`,
555
+ name: `sequence ${sequence.name}`,
556
+ condition: (ctx) => {
557
+ const next = ctx.world.getStateValue(key) ?? 0;
558
+ return next < sequence.steps.length && stepReady(sequence.steps[next], ctx.world, ctx.turn);
559
+ },
560
+ run: (ctx) => {
561
+ const next = ctx.world.getStateValue(key) ?? 0;
562
+ ctx.world.setStateValue(key, next + 1);
563
+ ctx.world.setStateValue(firedKey, ctx.turn);
564
+ const step = sequence.steps[next];
565
+ return this.narrated(this.execStatements(step.body, { world: ctx.world, occurrence: next + 1 }));
566
+ },
567
+ });
568
+ }
569
+ // Entity every-turn clauses (`on every turn while …[, once]` in a
570
+ // create block): one daemon per clause, `it` = the owning entity
571
+ // (stickiness — the ownership package's replacement for floating
572
+ // `once <cond>` rules).
573
+ this.ir.entities.forEach((irEntity) => {
574
+ irEntity.onClauses.forEach((clause, clauseIndex) => {
575
+ if (clause.binding !== 'every-turn')
576
+ return;
577
+ const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}entity-turn.${irEntity.id}.${clauseIndex}`;
578
+ daemons.push({
579
+ id: `chord.entity-turn.${irEntity.id}.${clauseIndex}`,
580
+ name: `on every turn (${irEntity.id})`,
581
+ run: (ctx) => {
582
+ // Presence gate (decision 10): performances need an audience —
583
+ // the clause does not FIRE off-stage. Checked before the
584
+ // condition so an off-stage `one chance in N` never draws the
585
+ // RNG (AC-5 determinism for on-stage firings) and `, once` is
586
+ // never consumed unwitnessed. Presence, not sight.
587
+ if (!this.playerPresentAt(ctx.world, irEntity.id))
588
+ return [];
589
+ const evalCtx = { world: ctx.world, it: irEntity.id };
590
+ if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
591
+ return [];
592
+ const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
593
+ if (clause.once && fired > 1)
594
+ return []; // `, once` (D5)
595
+ ctx.world.setStateValue(key, fired);
596
+ evalCtx.occurrence = fired;
597
+ return this.narrated(this.execStatements(clause.body, evalCtx));
598
+ },
599
+ });
600
+ });
601
+ });
602
+ // Every-turn trait clauses (`on every turn while …[, once]`): one
603
+ // daemon per clause, evaluated per entity carrying the trait. The
604
+ // composition condition (`chatty while not after-hours`) gates per
605
+ // entity per turn (Prerequisite 2's NPC-behavior shape).
606
+ this.ir.traits.forEach((trait) => {
607
+ trait.onClauses.forEach((clause, clauseIndex) => {
608
+ if (clause.binding !== 'every-turn')
609
+ return;
610
+ const traitType = state_keys_1.CHORD_TRAIT_PREFIX + trait.name;
611
+ daemons.push({
612
+ id: `chord.trait-turn.${trait.name}.${clauseIndex}`,
613
+ name: `on every turn (${trait.name})`,
614
+ run: (ctx) => {
615
+ const out = [];
616
+ for (const irEntity of this.ir.entities) {
617
+ const comp = irEntity.traits.find((t) => t.name === trait.name);
618
+ if (!comp)
619
+ continue;
620
+ const worldId = this.host.entityId(irEntity.id);
621
+ const entity = worldId ? ctx.world.getEntity(worldId) : undefined;
622
+ if (!entity?.has(traitType))
623
+ continue;
624
+ // Presence gate (decision 10) — before any condition so the
625
+ // RNG stream and `, once` are untouched off-stage.
626
+ if (!this.playerPresentAt(ctx.world, irEntity.id))
627
+ continue;
628
+ const evalCtx = { world: ctx.world, it: irEntity.id };
629
+ if (comp.condition && !this.evaluator.evalCondition(comp.condition, evalCtx))
630
+ continue;
631
+ if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
632
+ continue;
633
+ const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait-turn.${trait.name}.${clauseIndex}.${irEntity.id}`;
634
+ const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
635
+ if (clause.once && fired > 1)
636
+ continue; // `, once` (D5)
637
+ ctx.world.setStateValue(key, fired);
638
+ evalCtx.occurrence = fired;
639
+ out.push(...this.execStatements(clause.body, evalCtx));
640
+ }
641
+ return this.narrated(out);
642
+ },
643
+ });
644
+ });
645
+ });
646
+ // Z3: a `disappeared` narration enqueued OUTSIDE statement execution
647
+ // (a TS-initiated removeEntity — daemon, hatch, interceptor) has no
648
+ // report pass to drain it; this daemon delivers it on the tick.
649
+ // Registered only when the channel is authored, so channel-less
650
+ // stories keep their exact daemon roster.
651
+ const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
652
+ if (Object.keys(table).some((key) => key.endsWith('.disappeared'))) {
653
+ daemons.push({
654
+ id: 'chord.channel-drain',
655
+ name: 'Z3 channel narration drain',
656
+ condition: () => this.pendingChannelEvents.length > 0,
657
+ run: () => this.drainChannelEvents(),
658
+ });
659
+ }
660
+ return daemons;
661
+ }
662
+ /** Scheduler-returned events must narrate to reach the transcript. */
663
+ narrated(events) {
664
+ return events.map((e) => ({ ...e, narrate: true }));
665
+ }
666
+ /**
667
+ * Decision 10 presence semantics: the player shares the owner's location.
668
+ * A room owner means the player is IN that room; for anything else the
669
+ * two share a containing room (same co-location rule as can-see/can-reach
670
+ * — presence, not sight, so the snake speaks in darkness).
671
+ */
672
+ playerPresentAt(world, irEntityId) {
673
+ const ownerId = this.host.entityId(irEntityId);
674
+ const playerId = world.getPlayer()?.id;
675
+ if (!ownerId || !playerId)
676
+ return false;
677
+ if (ownerId === playerId)
678
+ return true;
679
+ const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
680
+ const owner = world.getEntity(ownerId);
681
+ if (owner?.has(world_model_1.TraitType.ROOM))
682
+ return playerRoom === ownerId;
683
+ const ownerRoom = world.getContainingRoom(ownerId)?.id ?? world.getLocation(ownerId);
684
+ return ownerRoom !== undefined && ownerRoom === playerRoom;
685
+ }
686
+ /**
687
+ * Fire the target entity's `after <verb> it` clauses when a dispatch
688
+ * action completes — the loader-internal mechanism the Phase 1 spike
689
+ * confirmed (interceptor hooks never fire on the dispatch path; the
690
+ * runtime owns these actions, so reactions run in their report phase).
691
+ */
692
+ fireAfterClauses(actionName, target, world) {
693
+ const out = [];
694
+ const targetIrId = this.host.irIdOf(target.id);
695
+ if (targetIrId === undefined)
696
+ return out;
697
+ const irEntity = this.ir.entities.find((e) => e.id === targetIrId);
698
+ if (!irEntity)
699
+ return out;
700
+ irEntity.onClauses.forEach((clause, clauseIndex) => {
701
+ if (clause.clauseKind !== 'after' || clause.action !== actionName)
702
+ return;
703
+ const ctx = { world, it: targetIrId };
704
+ if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
705
+ return;
706
+ const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}after.${irEntity.id}.${actionName}.${clauseIndex}`;
707
+ const occurrence = (world.getStateValue(key) ?? 0) + 1;
708
+ if (clause.once && occurrence > 1)
709
+ return; // `, once` (D5)
710
+ world.setStateValue(key, occurrence);
711
+ ctx.occurrence = occurrence;
712
+ ctx.decisions = this.snapshotDecisions(clause.body, ctx);
713
+ out.push(...this.execStatements(clause.body, ctx));
714
+ });
715
+ return out;
716
+ }
717
+ // ------------------------------------------------------ derived (dark)
718
+ derivedDarkRooms() {
719
+ const out = [];
720
+ for (const entity of this.ir.entities) {
721
+ for (const trait of entity.traits) {
722
+ if (trait.name === 'dark' && trait.condition)
723
+ out.push({ entity, condition: trait.condition });
724
+ }
725
+ }
726
+ return out;
727
+ }
728
+ /**
729
+ * Re-evaluate derived properties: `dark while` into `RoomTrait.isDark`,
730
+ * and `is blocked while <cond>` into blockedExits (block/unblock per the
731
+ * condition's current truth — grammar log 2026-07-10).
732
+ */
733
+ recomputeDerived(world) {
734
+ for (const { entity, condition } of this.derivedDarkRooms()) {
735
+ const worldId = this.host.entityId(entity.id);
736
+ const room = worldId ? world.getEntity(worldId)?.get(world_model_1.TraitType.ROOM) : undefined;
737
+ if (!room)
738
+ continue;
739
+ room.isDark = this.evaluator.evalCondition(condition, { world });
740
+ }
741
+ for (const irEntity of this.ir.entities) {
742
+ for (const blocked of irEntity.blockedExits) {
743
+ if (!blocked.condition)
744
+ continue;
745
+ const worldId = this.host.entityId(irEntity.id);
746
+ const room = worldId ? world.getEntity(worldId) : undefined;
747
+ if (!room)
748
+ continue;
749
+ const direction = world_model_1.Direction[blocked.direction.toUpperCase()];
750
+ if (!direction)
751
+ continue;
752
+ const holds = this.evaluator.evalCondition(blocked.condition, { world, it: irEntity.id });
753
+ if (holds) {
754
+ world_model_1.RoomBehavior.blockExit(room, direction, this.blockedPhraseText(blocked.phraseKey));
755
+ }
756
+ else {
757
+ world_model_1.RoomBehavior.unblockExit(room, direction);
758
+ }
759
+ }
760
+ }
761
+ }
762
+ /** Single-variant text of a blocked-exit phrase (mirrors the loader's read). */
763
+ blockedPhraseText(key) {
764
+ const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
765
+ return phrase?.variants[0]?.text ?? '';
766
+ }
767
+ /** True when any entity declares a conditional blocked exit. */
768
+ hasConditionalBlockedExits() {
769
+ return this.ir.entities.some((e) => e.blockedExits.some((b) => b.condition !== null));
770
+ }
771
+ // ------------------------------------------------------------ statements
772
+ /**
773
+ * Execute a statement tree. `phase` narrows which leaves act:
774
+ * 'mutations' applies change/set/move only; 'reports' collects
775
+ * phrase/emit/win/lose only; 'all' (rules) does both in source order.
776
+ */
777
+ execStatements(body, ctx, phase = 'all') {
778
+ const events = [];
779
+ // Statement `when` suffix (ratchet D7): the statement acts only if the
780
+ // condition holds at execution. Evaluated per phase-pass over the same
781
+ // snapshot world — mutations and reports agree because the suffix runs
782
+ // before either phase's own mutations of this statement.
783
+ const whenHolds = (stmt) => !stmt.stmtWhen || this.evaluator.evalCondition(stmt.stmtWhen, ctx);
784
+ for (const stmt of body) {
785
+ switch (stmt.kind) {
786
+ case 'phrase':
787
+ if (phase !== 'mutations' && whenHolds(stmt))
788
+ events.push(this.phraseEvent(stmt.phraseKey, ctx, stmt.params));
789
+ break;
790
+ case 'emit':
791
+ if (phase !== 'mutations' && whenHolds(stmt))
792
+ events.push(this.rawEvent(stmt.event, {}));
793
+ break;
794
+ case 'win':
795
+ case 'lose':
796
+ if (phase !== 'mutations' && whenHolds(stmt)) {
797
+ if (stmt.phraseKey)
798
+ events.push(this.phraseEvent(stmt.phraseKey, ctx));
799
+ events.push(this.host.triggerEnding(ctx.world, stmt.kind === 'win' ? 'victory' : 'defeat', stmt.phraseKey ?? undefined));
800
+ }
801
+ break;
802
+ case 'change': {
803
+ if (phase !== 'reports' && whenHolds(stmt)) {
804
+ if (stmt.entity.kind === 'story') {
805
+ // `change the story to <state>` — the story object's phase (D2).
806
+ this.checkForwardMarch(this.ir.story.states, this.ir.story.reversible, ctx.world.getStateValue(state_keys_1.CHORD_STORY_STATE_KEY), stmt.state, 'the story', stmt.span);
807
+ ctx.world.setStateValue(state_keys_1.CHORD_STORY_STATE_KEY, stmt.state);
808
+ }
809
+ else {
810
+ const irId = this.irIdOfValue(stmt.entity, ctx);
811
+ const set = this.stateSetOf(irId, stmt.state);
812
+ if (set) {
813
+ this.checkForwardMarch(set.states, set.reversible, ctx.world.getStateValue(state_keys_1.CHORD_STATE_PREFIX + irId), stmt.state, irId, stmt.span);
814
+ }
815
+ ctx.world.setStateValue(state_keys_1.CHORD_STATE_PREFIX + irId, stmt.state);
816
+ }
817
+ }
818
+ break;
819
+ }
820
+ case 'move': {
821
+ if (phase !== 'reports' && whenHolds(stmt)) {
822
+ const thing = this.evaluator.entityValue(stmt.entity, ctx);
823
+ const place = this.evaluator.entityValue(stmt.place, ctx);
824
+ this.moveWithLifecycle(thing, place, ctx);
825
+ }
826
+ break;
827
+ }
828
+ case 'remove': {
829
+ if (phase !== 'reports' && whenHolds(stmt)) {
830
+ const thing = this.evaluator.entityValue(stmt.entity, ctx);
831
+ // Z6 (ADR-213): the loader's pre-removal observer fires inside
832
+ // removeEntity and enqueues any witnessed `disappeared`
833
+ // narration; the report-collecting pass drains it. Never
834
+ // rendered inline from the mutation pass.
835
+ ctx.world.removeEntity(thing);
836
+ }
837
+ break;
838
+ }
839
+ case 'set': {
840
+ if (phase !== 'reports') {
841
+ const value = this.evaluator.evalValue(stmt.value, ctx);
842
+ if (stmt.target.kind === 'field') {
843
+ // Trait data fields (`set its treats to 3`) write the entity's
844
+ // chord trait instance — world state via traits (AC-6-safe).
845
+ const baseId = this.evaluator.entityValue(stmt.target.base, ctx);
846
+ this.writeChordTraitField(ctx.world, baseId, stmt.target.field, value, stmt.span);
847
+ }
848
+ else {
849
+ throw new errors_1.LoadError('`set` targets a trait data field.', stmt.span);
850
+ }
851
+ }
852
+ break;
853
+ }
854
+ case 'award': {
855
+ if (phase !== 'reports' && whenHolds(stmt)) {
856
+ // `award <score>` — dedup by identity (ADR-129), so repeat
857
+ // awards are no-ops and `, once` is automatic. Names arrive
858
+ // owner-qualified from the analyzer (ratchet D12).
859
+ if (stmt.expression.length !== 1) {
860
+ throw new errors_1.LoadError('Only `award <score-name>` is supported (expression awards are later scope).', stmt.span);
861
+ }
862
+ const name = stmt.expression[0];
863
+ const worth = this.scoreWorth.get(name);
864
+ if (worth === undefined) {
865
+ throw new errors_1.LoadError(`\`${name}\` is not a declared score.`, stmt.span);
866
+ }
867
+ ctx.world.awardScore(name, worth, name);
868
+ }
869
+ break;
870
+ }
871
+ case 'refuse':
872
+ case 'must':
873
+ case 'refuse-when':
874
+ // The refusal partition is consumed by findRefusal (validate
875
+ // phase); nothing to do in execute/report passes.
876
+ break;
877
+ case 'select-on': {
878
+ const decided = ctx.decisions?.get(stmt) ?? this.decideSelectOn(stmt, ctx);
879
+ const arm = stmt.arms.find((a) => a.value === decided);
880
+ if (arm)
881
+ events.push(...this.execStatements(arm.body, ctx, phase));
882
+ break;
883
+ }
884
+ case 'select-strategy': {
885
+ const index = this.decideStrategy(stmt, ctx);
886
+ const alternative = stmt.alternatives[index];
887
+ if (alternative)
888
+ events.push(...this.execStatements(alternative, ctx, phase));
889
+ break;
890
+ }
891
+ case 'ordinal':
892
+ if (ctx.occurrence === stmt.ordinal) {
893
+ events.push(...this.execStatements(stmt.body, ctx, phase));
894
+ }
895
+ break;
896
+ case 'each':
897
+ // E3 (ratchet 2026-07-12): run the body once per matching entity
898
+ // in creation order, `the match` bound to it; `it` and every
899
+ // other binding pass through untouched. Empty set = no-op.
900
+ for (const irId of this.eachMatches(stmt, ctx)) {
901
+ events.push(...this.execStatements(stmt.body, { ...ctx, match: irId }, phase));
902
+ }
903
+ break;
904
+ }
905
+ }
906
+ // Z3: witnessed lifecycle narration enqueued during mutation phases
907
+ // (move/remove above; the removal observer) lands in the next report-
908
+ // collecting pass. Mutations-only passes never drain — their return
909
+ // value is discarded by the interceptor call sites.
910
+ if (phase !== 'mutations')
911
+ events.push(...this.drainChannelEvents());
912
+ return events;
913
+ }
914
+ /**
915
+ * Z3: `move` with witnessed-only lifecycle narration (D11). `exited`
916
+ * fires when the player shares the mover's SOURCE room at the transition
917
+ * (and the move really changes rooms); `entered` when the player shares
918
+ * the DESTINATION room after arrival. Unwitnessed transitions narrate
919
+ * nothing and consume nothing; narration is enqueued, never emitted
920
+ * inline from the mutation pass. Moving the player itself never narrates.
921
+ *
922
+ * @param thingWorldId the moved entity's world id
923
+ * @param placeWorldId the destination's world id
924
+ * @param ctx the executing context (live world)
925
+ */
926
+ moveWithLifecycle(thingWorldId, placeWorldId, ctx) {
927
+ const world = ctx.world;
928
+ const roomOf = (id) => world.getContainingRoom(id)?.id ?? world.getLocation(id);
929
+ const fromRoom = roomOf(thingWorldId);
930
+ world.moveEntity(thingWorldId, placeWorldId);
931
+ const playerId = world.getPlayer()?.id;
932
+ if (!playerId || thingWorldId === playerId)
933
+ return;
934
+ const irId = this.host.irIdOf(thingWorldId);
935
+ if (!irId)
936
+ return;
937
+ const toRoom = roomOf(thingWorldId);
938
+ if (fromRoom === toRoom)
939
+ return; // not a room transition — nothing to witness
940
+ const playerRoom = roomOf(playerId);
941
+ if (playerRoom === undefined)
942
+ return;
943
+ if (playerRoom === fromRoom) {
944
+ const event = this.channelEvent(irId, 'exited', world);
945
+ if (event)
946
+ this.enqueueChannelEvent(event);
947
+ }
948
+ if (playerRoom === toRoom) {
949
+ const event = this.channelEvent(irId, 'entered', world);
950
+ if (event)
951
+ this.enqueueChannelEvent(event);
952
+ }
953
+ }
954
+ /**
955
+ * The declared set a `change` target state belongs to on an entity, with
956
+ * its D4 policy — a composed trait's set, or the entity's own `states:`
957
+ * line (merged list minus trait states). Null when the state is unknown
958
+ * (the analyzer gates that; being lenient here keeps the check pure).
959
+ */
960
+ stateSetOf(irId, state) {
961
+ const irEntity = this.ir.entities.find((e) => e.id === irId);
962
+ if (!irEntity)
963
+ return null;
964
+ const traitStates = new Set();
965
+ for (const comp of irEntity.traits) {
966
+ const trait = this.ir.traits.find((t) => t.name === comp.name);
967
+ if (!trait)
968
+ continue;
969
+ if (trait.states.includes(state)) {
970
+ return { states: trait.states, reversible: trait.statesReversible };
971
+ }
972
+ for (const s of trait.states)
973
+ traitStates.add(s);
974
+ }
975
+ const own = irEntity.states.filter((s) => !traitStates.has(s));
976
+ return own.includes(state) ? { states: own, reversible: irEntity.statesReversible } : null;
977
+ }
978
+ /**
979
+ * D4 forward-march, runtime half: within a non-reversible set, `change`
980
+ * may only move forward in declaration order. (The analyzer catches the
981
+ * statically provable case — change-to-initial; this catches the rest
982
+ * with the live current state.) Cross-set transitions and same-state
983
+ * no-ops pass.
984
+ */
985
+ checkForwardMarch(states, reversible, current, target, ownerDesc, span) {
986
+ if (reversible || typeof current !== 'string')
987
+ return;
988
+ const from = states.indexOf(current);
989
+ const to = states.indexOf(target);
990
+ if (from >= 0 && to >= 0 && to < from) {
991
+ throw new errors_1.LoadError(`\`change\` to \`${target}\` moves ${ownerDesc} backward in a forward-only set (currently \`${current}\`) — add \`, reversible\` to the \`states:\` line to permit back-transitions (D4).`, span);
992
+ }
993
+ }
994
+ /**
995
+ * Leading-refusal scan (§5.4 validate partition): unconditional `refuse`,
996
+ * `must` requirements (refuse when the requirement FAILS, ratchet D6),
997
+ * and `refuse when` prohibitions (refuse when the hazard HOLDS) — checked
998
+ * in source order until the first non-refusal statement.
999
+ */
1000
+ findRefusal(body, ctx) {
1001
+ for (const stmt of body) {
1002
+ if (stmt.kind === 'refuse')
1003
+ return stmt.phraseKey;
1004
+ if (stmt.kind === 'must') {
1005
+ if (!this.evaluator.evalCondition(stmt.condition, ctx))
1006
+ return stmt.phraseKey;
1007
+ continue;
1008
+ }
1009
+ if (stmt.kind === 'refuse-when') {
1010
+ if (this.evaluator.evalCondition(stmt.condition, ctx))
1011
+ return stmt.phraseKey;
1012
+ continue;
1013
+ }
1014
+ break; // first non-refusal statement ends the validate partition
1015
+ }
1016
+ return null;
1017
+ }
1018
+ /**
1019
+ * Snapshot every branching decision (`if` and `select-on`) before
1020
+ * mutations run, walking only the branches actually taken (§5.4: the
1021
+ * report phase must see the same routing the execute phase did).
1022
+ */
1023
+ snapshotDecisions(body, ctx) {
1024
+ const decisions = new Map();
1025
+ const walk = (stmts) => {
1026
+ for (const stmt of stmts) {
1027
+ switch (stmt.kind) {
1028
+ case 'select-on': {
1029
+ const decided = this.decideSelectOn(stmt, ctx);
1030
+ decisions.set(stmt, decided);
1031
+ const arm = stmt.arms.find((a) => a.value === decided);
1032
+ if (arm)
1033
+ walk(arm.body);
1034
+ break;
1035
+ }
1036
+ case 'select-strategy':
1037
+ stmt.alternatives.forEach((a) => walk(a));
1038
+ break;
1039
+ case 'ordinal':
1040
+ if (ctx.occurrence === stmt.ordinal)
1041
+ walk(stmt.body);
1042
+ break;
1043
+ case 'each':
1044
+ // Pin the match set pre-mutation (iteration is routing, same
1045
+ // as a select arm): joined IR ids, '' for the empty set. The
1046
+ // body is NOT walked — a select-on inside an `each` body may
1047
+ // decide differently per match, and this map is keyed by
1048
+ // statement identity alone, so those decide live per pass
1049
+ // (recorded follow-up; no shipped construct hits it).
1050
+ decisions.set(stmt, this.evaluator.matchesOf(stmt.condition, ctx).join('|'));
1051
+ break;
1052
+ default:
1053
+ break;
1054
+ }
1055
+ }
1056
+ };
1057
+ walk(body);
1058
+ return decisions;
1059
+ }
1060
+ /**
1061
+ * The match set for an `each` block: the pre-mutation snapshot when one
1062
+ * exists (§5.4 — the report pass must visit the same entities the
1063
+ * execute pass did, even after the body's own mutations change who
1064
+ * matches), else a live enumeration (single-pass contexts: event
1065
+ * clauses, sequences, daemons, and blocks nested inside another `each`).
1066
+ */
1067
+ eachMatches(stmt, ctx) {
1068
+ const snapped = ctx.decisions?.get(stmt);
1069
+ if (snapped !== undefined)
1070
+ return snapped ? snapped.split('|') : [];
1071
+ return this.evaluator.matchesOf(stmt.condition, ctx);
1072
+ }
1073
+ decideSelectOn(stmt, ctx) {
1074
+ return String(this.evaluator.evalValue(stmt.subject, ctx));
1075
+ }
1076
+ decideStrategy(stmt, ctx) {
1077
+ const count = stmt.alternatives.length;
1078
+ if (count === 0)
1079
+ return 0;
1080
+ // Occurrence-ordered strategies key off world state; randomly keys off
1081
+ // the persisted chance stream (via one draw per firing). Sticky (Z5)
1082
+ // reuses the same slot with the Choice encoding instead of an
1083
+ // occurrence count: stored = chosen index + 1, 0/undefined = unchosen.
1084
+ const key = state_keys_1.CHORD_OCCURRENCE_PREFIX + `select.${stmt.span.line}`;
1085
+ if (stmt.strategy === 'sticky') {
1086
+ const stored = ctx.world.getStateValue(key);
1087
+ if (stored && stored > 0)
1088
+ return Math.min(stored - 1, count - 1);
1089
+ const i = this.randomIndex(count, ctx);
1090
+ ctx.world.setStateValue(key, i + 1);
1091
+ return i;
1092
+ }
1093
+ const n = ctx.world.getStateValue(key) ?? 0;
1094
+ ctx.world.setStateValue(key, n + 1);
1095
+ switch (stmt.strategy) {
1096
+ case 'cycling':
1097
+ return n % count;
1098
+ case 'stopping':
1099
+ return Math.min(n, count - 1);
1100
+ case 'first-time':
1101
+ return n === 0 ? 0 : Math.min(1, count - 1);
1102
+ case 'randomly':
1103
+ return this.randomIndex(count, ctx);
1104
+ default:
1105
+ throw new errors_1.LoadError(`Unknown select strategy \`${stmt.strategy}\`.`, stmt.span);
1106
+ }
1107
+ }
1108
+ randomIndex(count, ctx) {
1109
+ // Reuse the persisted chance stream: draw until a bucket resolves.
1110
+ for (let i = 0; i < count - 1; i++) {
1111
+ if (this.evaluator.evalCondition({ kind: 'chance', n: count - i }, ctx))
1112
+ return i;
1113
+ }
1114
+ return count - 1;
1115
+ }
1116
+ // --------------------------------------------------------------- phrases
1117
+ /** Z3 lifecycle narration awaiting the next report-collecting pass (never rendered inline — ADR-213 §2). */
1118
+ pendingChannelEvents = [];
1119
+ /** Enqueue witnessed channel narration (Z3) — it lands in the turn's report pass. */
1120
+ enqueueChannelEvent(event) {
1121
+ this.pendingChannelEvents.push(event);
1122
+ }
1123
+ /** Drain pending channel narration (Z3) — report-collecting passes and the drain daemon consume it. */
1124
+ drainChannelEvents() {
1125
+ return this.pendingChannelEvents.splice(0, this.pendingChannelEvents.length);
1126
+ }
1127
+ /**
1128
+ * Z3: build the channel phrase event for an owner (`entered` / `exited` /
1129
+ * `disappeared`). The phrase is the owner's `<irId>.<channel>` block;
1130
+ * `Choice` counters key `(ownerWorldId, channel)` — ADR-212 §4's owner +
1131
+ * channel-key convention, shared with the `present` slot entries.
1132
+ *
1133
+ * @param ownerIrId the channel owner's IR entity id
1134
+ * @param channel the channel key (`entered`/`exited`/`disappeared`)
1135
+ * @param world the live world
1136
+ * @returns the phrase event, or null when the owner has no such block
1137
+ */
1138
+ channelEvent(ownerIrId, channel, world) {
1139
+ const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
1140
+ if (!table[`${ownerIrId}.${channel}`])
1141
+ return null;
1142
+ const ownerWorldId = this.host.entityId(ownerIrId);
1143
+ if (!ownerWorldId)
1144
+ return null;
1145
+ return this.phraseEvent(`${ownerIrId}.${channel}`, { world, it: ownerIrId }, undefined, {
1146
+ entityId: ownerWorldId,
1147
+ messageKey: channel,
1148
+ });
1149
+ }
1150
+ /**
1151
+ * Build the semantic event for `phrase <key>`: entity-scoped override
1152
+ * resolution (prereq 4), strategy variants as a persistent Choice atom,
1153
+ * and hatch producers bound by marker name.
1154
+ *
1155
+ * @param counter Z3 channel counter identity — overrides the default
1156
+ * `('chord', overrideKey)` Choice keying with `(owner, channelKey)`.
1157
+ */
1158
+ phraseEvent(key, ctx, stmtParams, counter) {
1159
+ const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
1160
+ const overrideKey = ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
1161
+ const phrase = table[overrideKey];
1162
+ if (!phrase)
1163
+ throw new errors_1.LoadError(`Phrase \`${key}\` is missing from the IR at emit time.`);
1164
+ const params = {};
1165
+ // Authored `with <param> = <value>` bindings (zoo-chain follow-up,
1166
+ // 2026-07-12): entity values pass as their display name (the template's
1167
+ // article hint does the rest); scalars pass through.
1168
+ for (const p of stmtParams ?? []) {
1169
+ const value = this.evaluator.evalValue(p.value, ctx);
1170
+ const asEntity = typeof value === 'string' ? ctx.world.getEntity(value) : undefined;
1171
+ params[p.param] = asEntity ? asEntity.name : value;
1172
+ }
1173
+ for (const variant of phrase.variants) {
1174
+ for (const marker of variant.markers) {
1175
+ const producer = this.host.producers.get(marker);
1176
+ if (producer) {
1177
+ // Params carry phrase ATOMS, not functions — the template binder
1178
+ // string-coerces anything that isn't a Phrase (ADR-196: producers
1179
+ // are invoked at staging, their atoms realized by the assembler).
1180
+ // The context is the narrow staging facade (design.md §5.6): a
1181
+ // producer reaching outside it fails HERE, named, not as an
1182
+ // anonymous TypeError downstream.
1183
+ try {
1184
+ params[marker] = producer((0, hatch_context_1.stagingRenderContext)(ctx.world));
1185
+ }
1186
+ catch (error) {
1187
+ throw new errors_1.LoadError(`Hatch \`${marker}\` threw while staging phrase \`${overrideKey}\`: ${error instanceof Error ? error.message : String(error)}. Hatches see the narrow staging context only (design.md §5.6).`);
1188
+ }
1189
+ }
1190
+ }
1191
+ }
1192
+ // Grammar-slot params (`{the target}` in a dispatch-action or trait
1193
+ // clause body, zoo-chain fixes 2026-07-12): the slot entity's name
1194
+ // binds as the NounPhrase-default string — the template's own article
1195
+ // hint supplies `the`/`a`. Producers above win on a name collision.
1196
+ if (ctx.slots) {
1197
+ for (const [name, worldId] of Object.entries(ctx.slots)) {
1198
+ if (params[name] !== undefined)
1199
+ continue;
1200
+ const slotEntity = ctx.world.getEntity(worldId);
1201
+ if (slotEntity)
1202
+ params[name] = slotEntity.name;
1203
+ }
1204
+ }
1205
+ if (phrase.verbatim) {
1206
+ // `{verbatim:text}` template (loader registration) — the atom is
1207
+ // exempt from whitespace collapse, so line structure and interior
1208
+ // spacing survive as authored (grammar log 2026-07-10).
1209
+ params.text = phrase.variants[0]?.text ?? '';
1210
+ }
1211
+ else if (phrase.strategy) {
1212
+ const choice = {
1213
+ kind: 'choice',
1214
+ alternatives: phrase.variants.map((v) => ({ kind: 'literal', text: (0, text_1.withLineBreaks)(v.text) })),
1215
+ selector: exports.STRATEGY_SELECTOR[phrase.strategy],
1216
+ entityId: counter?.entityId ?? 'chord',
1217
+ messageKey: counter?.messageKey ?? overrideKey,
1218
+ };
1219
+ params.variants = choice;
1220
+ }
1221
+ return this.rawEvent('chord.phrase', { messageId: overrideKey, params });
1222
+ }
1223
+ rawEvent(type, data) {
1224
+ return {
1225
+ id: `chord-${type}-${this.eventSeq++}`,
1226
+ type,
1227
+ timestamp: Date.now(),
1228
+ entities: {},
1229
+ data,
1230
+ };
1231
+ }
1232
+ // --------------------------------------------------------------- helpers
1233
+ irIdOfValue(value, ctx) {
1234
+ if (value.kind === 'entity')
1235
+ return value.id;
1236
+ if (value.kind === 'it') {
1237
+ if (!ctx.it)
1238
+ throw new errors_1.LoadError('`it` used outside an entity-scoped clause.');
1239
+ return ctx.it;
1240
+ }
1241
+ const worldId = this.evaluator.entityValue(value, ctx);
1242
+ const irId = this.host.irIdOf(worldId);
1243
+ if (!irId)
1244
+ throw new errors_1.LoadError('Cannot change the state of a non-story entity.');
1245
+ return irId;
1246
+ }
1247
+ }
1248
+ exports.ChordRuntime = ChordRuntime;
1249
+ //# sourceMappingURL=runtime.js.map