@sharpee/story-loader 3.1.0 → 3.5.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 CHANGED
@@ -1,13 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ChordRuntime = exports.ChordBehaviorTrait = exports.STRATEGY_SELECTOR = void 0;
4
+ const chord_1 = require("@sharpee/chord");
5
+ const engine_1 = require("@sharpee/engine");
6
+ const phrasebook_data_js_1 = require("./phrasebook-data.js");
4
7
  const world_model_1 = require("@sharpee/world-model");
5
8
  const stdlib_1 = require("@sharpee/stdlib");
6
- const errors_1 = require("./errors");
7
- const state_keys_1 = require("./state-keys");
8
- const text_1 = require("./text");
9
- const hatch_context_1 = require("./hatch-context");
10
- const event_contract_1 = require("./event-contract");
9
+ const errors_js_1 = require("./errors.js");
10
+ const state_keys_js_1 = require("./state-keys.js");
11
+ const text_js_1 = require("./text.js");
12
+ const hatch_context_js_1 = require("./hatch-context.js");
13
+ const event_contract_js_1 = require("./event-contract.js");
14
+ const event_id_map_js_1 = require("./event-id-map.js");
15
+ const message_alias_map_js_1 = require("./message-alias-map.js");
11
16
  /**
12
17
  * Chord strategy adverb → phrase-algebra Choice selector (ADR-196).
13
18
  * The Z5 table (ADR-211 Decision 4): adverbs mirror the selectors 1:1;
@@ -28,6 +33,11 @@ class ChordBehaviorTrait {
28
33
  type = ChordBehaviorTrait.type;
29
34
  }
30
35
  exports.ChordBehaviorTrait = ChordBehaviorTrait;
36
+ /**
37
+ * The two gerunds a topic table serves (ADR-239 D1 — one table, ask and
38
+ * tell alike). The table rides these actions' interceptor dispatch.
39
+ */
40
+ const TOPIC_GERUNDS = ['asking', 'telling'];
31
41
  class ChordRuntime {
32
42
  ir;
33
43
  host;
@@ -58,11 +68,19 @@ class ChordRuntime {
58
68
  return;
59
69
  // Event clauses (`after entering it`) bind to the event stream per
60
70
  // the selector contract — the ownership package's replacement for
61
- // floating `when` rules.
62
- if (event_contract_1.EVENT_TRIGGERS[clause.action]) {
63
- this.bindEventClause(world, entity, clause, clauseIndex);
71
+ // floating `when` rules. A REGION owner re-homes the verb onto the
72
+ // crossing events (ADR-236 D6): entering → region_entered, leaving
73
+ // region_exited.
74
+ const trigger = this.eventTriggerFor(entity, clause);
75
+ if (trigger) {
76
+ this.bindEventClause(world, entity, clause, clauseIndex, trigger);
64
77
  return;
65
78
  }
79
+ if (event_contract_js_1.REGION_EVENT_TRIGGERS[clause.action]) {
80
+ // `leaving` exists only as a region crossing reaction (D6) — on
81
+ // any other owner it would silently never fire. Refuse at load.
82
+ throw new errors_js_1.LoadError(`\`${clause.clauseKind} ${clause.action} it\` — \`${clause.action}\` is a region crossing reaction (ADR-236), and \`${entity.name}\` is not a region. Put the clause on the region block whose boundary it reacts to.`, clause.span);
83
+ }
66
84
  // D5 fail-fast (ADR-228): only bind clauses something will consult.
67
85
  if (!this.isConsultedGerund(clause.action)) {
68
86
  if (this.isDispatchAction(clause.action)) {
@@ -72,7 +90,7 @@ class ChordRuntime {
72
90
  if (clause.clauseKind === 'after')
73
91
  return;
74
92
  // …but an entity `on` clause has no dispatch surface at all.
75
- throw new errors_1.LoadError(`\`on ${clause.action} it\` — \`${clause.action}\` is a Chord dispatch action, and entity \`on\` clauses never fire on the dispatch path. Move the clause into a trait (\`define trait … on ${clause.action} it\`) and compose the trait, or react with \`after ${clause.action} it\`.`, clause.span);
93
+ throw new errors_js_1.LoadError(`\`on ${clause.action} it\` — \`${clause.action}\` is a Chord dispatch action, and entity \`on\` clauses never fire on the dispatch path. Move the clause into a trait (\`define trait … on ${clause.action} it\`) and compose the trait, or react with \`after ${clause.action} it\`.`, clause.span);
76
94
  }
77
95
  throw this.deadGerundError(clause);
78
96
  }
@@ -82,21 +100,47 @@ class ChordRuntime {
82
100
  byAction.set(clause.action, list);
83
101
  });
84
102
  }
103
+ // ADR-239: topic tables ride the asking/telling dispatch. Every table
104
+ // owner gets an arm — with or without a catch-all clause (D5: with no
105
+ // catch-all declared, a miss simply returns {} and the action's
106
+ // unconditional unknown_topic/not_interested default stands).
107
+ for (const gerund of TOPIC_GERUNDS) {
108
+ for (const entity of this.ir.entities) {
109
+ if (!(entity.topics ?? []).length)
110
+ continue;
111
+ const list = byAction.get(gerund) ?? [];
112
+ if (!list.some((c) => c.entity.id === entity.id)) {
113
+ this.prepareTopicTarget(world, entity);
114
+ list.push({ entity, clause: null });
115
+ byAction.set(gerund, list);
116
+ }
117
+ }
118
+ }
85
119
  for (const [action, clauses] of byAction) {
86
- world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(clauses));
120
+ world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(action, clauses));
87
121
  }
88
122
  // Phase B: `define trait` clauses register per TRAIT TYPE — capability
89
123
  // behaviors for dispatch verbs, interceptors for standard-semantics
90
124
  // actions (§5.4 routing recorded on the IR by the analyzer).
91
125
  for (const trait of this.ir.traits) {
92
- const traitType = state_keys_1.CHORD_TRAIT_PREFIX + trait.name;
126
+ const traitType = state_keys_js_1.CHORD_TRAIT_PREFIX + trait.name;
127
+ const interceptorClauses = new Map();
128
+ const capabilityActions = new Set();
93
129
  for (const clause of trait.onClauses) {
94
130
  if (clause.binding === 'every-turn')
95
131
  continue; // scheduler phase (plan phase 5)
96
132
  if (clause.binding === 'role') {
97
- 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);
133
+ throw new errors_js_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);
98
134
  }
99
135
  if (clause.routing === 'capability') {
136
+ // The capability registry is (traitType, action)-keyed and
137
+ // last-wins: a second clause for the same dispatch action would
138
+ // silently OVERWRITE the first. Refuse legibly (never-guess)
139
+ // until the capability pair is wired.
140
+ if (capabilityActions.has(clause.action)) {
141
+ throw new errors_js_1.LoadError(`Trait \`${trait.name}\` declares more than one clause for the dispatch action \`${clause.action}\` — the capability registry holds one behavior per (trait, action), so the second clause could never fire. Merge the bodies into one clause.`, clause.span);
142
+ }
143
+ capabilityActions.add(clause.action);
100
144
  world.registerCapabilityBehavior(traitType, `chord.action.${clause.action}`, this.buildCapabilityBehavior(trait.name, clause));
101
145
  }
102
146
  else {
@@ -104,30 +148,197 @@ class ChordRuntime {
104
148
  // interceptor path, so its gerund must name a consulted action.
105
149
  if (!this.isConsultedGerund(clause.action))
106
150
  throw this.deadGerundError(clause);
107
- world.registerActionInterceptor(traitType, `if.action.${clause.action}`, this.buildTraitInterceptor(clause));
151
+ const list = interceptorClauses.get(clause.action) ?? [];
152
+ list.push(clause);
153
+ interceptorClauses.set(clause.action, list);
108
154
  }
109
155
  }
156
+ // One MERGED interceptor per (trait, action) — the D3 `on`/`after`
157
+ // pair both fire (the idempotent registry would otherwise keep only
158
+ // the last-registered clause, silently).
159
+ for (const [action, actionClauses] of interceptorClauses) {
160
+ world.registerActionInterceptor(traitType, `if.action.${action}`, this.mergeArms(actionClauses.map((clause, index) => this.buildTraitInterceptor(clause, `${trait.name}.${action}.${clause.clauseKind}.${index}`))));
161
+ }
110
162
  }
111
- // Derived properties (`dark while`, `is blocked while`) — recompute
112
- // when possession, location, or openable/switchable state changes.
113
- if (this.derivedDarkRooms().length > 0 || this.hasConditionalBlockedExits()) {
114
- for (const trigger of [
115
- 'if.event.taken',
116
- 'if.event.dropped',
117
- 'if.event.worn',
118
- 'if.event.removed',
119
- 'if.event.put_on',
120
- 'if.event.put_in',
121
- 'if.event.actor_moved',
122
- 'if.event.opened',
123
- 'if.event.closed',
124
- 'if.event.switched_on',
125
- 'if.event.switched_off',
126
- ]) {
127
- world.chainEvent(trigger, (_event, w) => {
128
- this.recomputeDerived(w);
129
- return null;
130
- }, { key: `chord.derived.${trigger}`, priority: 100 });
163
+ // Derived properties (`dark while`, blocked exits) — ADR-240: registered
164
+ // as live evaluators consulted at point of use. Nothing is stamped and
165
+ // nothing recomputes: mutations are instant, every reader sees current
166
+ // truth (the former eleven-event recompute trigger list is gone).
167
+ this.registerDerivedEvaluators(world);
168
+ // Phrasebooks (ADR-250 D4): one evaluator per book-covered key the
169
+ // story does not define — same ADR-240 seam, resolved at render time.
170
+ this.registerPhrasebookEvaluators(world);
171
+ // Message overrides (ADR-255 D6): standard-action message baselines,
172
+ // registered on the same seam so they beat the platform default but lose
173
+ // to per-entity/on-clause message ids.
174
+ this.registerMessageOverrideEvaluators(world);
175
+ }
176
+ /** Resolved books cache (built once per runtime — see resolvedBooks). */
177
+ books = null;
178
+ /**
179
+ * The story's phrasebooks in arbitration order, entries resolved:
180
+ * `define`d books carry their entries in the IR; `use`d books resolve
181
+ * from the packaged-data registry with manifest-key conformance (ADR-250
182
+ * D3 — LoadError on a missing book or a key mismatch), plus the D1 key
183
+ * rules the story compiler never saw the packaged data pass through.
184
+ */
185
+ resolvedBooks() {
186
+ if (this.books)
187
+ return this.books;
188
+ this.books = this.ir.phrasebooks.map((book) => {
189
+ if (book.source === 'define') {
190
+ return { name: book.name, condition: book.condition ?? null, entries: book.entries ?? {} };
191
+ }
192
+ const data = phrasebook_data_js_1.PHRASEBOOK_DATA.get(book.name);
193
+ if (!data) {
194
+ throw new errors_js_1.LoadError(`Phrasebook \`${book.name}\` is not in the load-time data registry — the compile-time manifest knows the name, the runtime has no entries for it.`);
195
+ }
196
+ const manifestKeys = [...(chord_1.PHRASEBOOK_REGISTRY.get(book.name)?.keys ?? [])].sort();
197
+ const dataKeys = Object.keys(data.entries).sort();
198
+ if (manifestKeys.join('') !== dataKeys.join('')) {
199
+ throw new errors_js_1.LoadError(`Phrasebook \`${book.name}\`: manifest keys [${manifestKeys.join(', ')}] and data keys [${dataKeys.join(', ')}] disagree.`);
200
+ }
201
+ for (const key of dataKeys) {
202
+ if (key.includes('.')) {
203
+ throw new errors_js_1.LoadError(`Phrasebook \`${book.name}\`: \`${key}\` is a dotted platform ID — books voice story keys only (ADR-250 D1).`);
204
+ }
205
+ }
206
+ return { name: book.name, condition: book.condition ?? null, entries: data.entries };
207
+ });
208
+ return this.books;
209
+ }
210
+ /** Book entries covering a key, in arbitration order (emit-time staging). */
211
+ bookEntriesFor(key) {
212
+ return this.resolvedBooks().flatMap((b) => (b.entries[key] ? [b.entries[key]] : []));
213
+ }
214
+ /**
215
+ * ADR-250 D4.2: register ONE evaluator per key that some book covers and
216
+ * the story does NOT define — story-beats-book is decided here,
217
+ * statically, so a story-defined key never pays predicate evaluation.
218
+ * The key convention (`phrasebook.template.<key>`) is built by the
219
+ * engine's read point (`phrasebookTemplateKey`) and here — nowhere else.
220
+ */
221
+ registerPhrasebookEvaluators(world) {
222
+ const books = this.resolvedBooks();
223
+ if (books.length === 0)
224
+ return;
225
+ const storyTable = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
226
+ const covered = new Set();
227
+ for (const book of books) {
228
+ for (const key of Object.keys(book.entries)) {
229
+ if (!storyTable[key])
230
+ covered.add(key);
231
+ }
232
+ }
233
+ for (const key of covered) {
234
+ world.registerEvaluator((0, engine_1.phrasebookTemplateKey)(key), (w) => this.resolvePhrasebook(key, w));
235
+ }
236
+ }
237
+ /**
238
+ * The evaluator body: first book in declaration order whose predicate
239
+ * holds AND that covers the key supplies it (ADR-245 D3 arbitration).
240
+ * Derivation mirrors registered phrases — verbatim/single/multi-variant
241
+ * templates and a Choice atom keyed `phrasebook.<book>` / key so
242
+ * cycling/first-time/sticky counters stay per (book, key) (ADR-250 D5)
243
+ * — keeping every Chord IR shape loader-side (ADR-210 direction rule).
244
+ */
245
+ resolvePhrasebook(key, world) {
246
+ for (const book of this.resolvedBooks()) {
247
+ const entry = book.entries[key];
248
+ if (!entry)
249
+ continue;
250
+ if (book.condition && !this.evaluator.evalCondition(book.condition, { world }))
251
+ continue;
252
+ const { template, params } = this.derivePhraseTemplate(entry, `phrasebook.${book.name}`, key);
253
+ return { book: book.name, key, template, ...(Object.keys(params).length > 0 ? { params } : {}) };
254
+ }
255
+ return undefined;
256
+ }
257
+ /**
258
+ * Derive the render-time template + bound params for an IR phrase body,
259
+ * shared by phrasebook resolution and ADR-255 message overrides: a verbatim
260
+ * or single-variant phrase becomes its literal template; a strategy/multi
261
+ * phrase becomes `{variants}` plus a Choice atom keyed by (counterEntityId,
262
+ * messageKey) so cycling/first-time/sticky counters stay per source+key.
263
+ */
264
+ derivePhraseTemplate(entry, counterEntityId, messageKey) {
265
+ const params = {};
266
+ let template;
267
+ if (entry.verbatim) {
268
+ template = '{verbatim:text}';
269
+ params.text = entry.variants[0]?.text ?? '';
270
+ }
271
+ else if (entry.strategy === null && entry.variants.length === 1) {
272
+ template = (0, text_js_1.withLineBreaks)(entry.variants[0].text);
273
+ }
274
+ else {
275
+ template = '{variants}';
276
+ if (entry.strategy) {
277
+ const choice = {
278
+ kind: 'choice',
279
+ alternatives: entry.variants.map((v) => ({ kind: 'literal', text: (0, text_js_1.withLineBreaks)(v.text) })),
280
+ selector: exports.STRATEGY_SELECTOR[entry.strategy],
281
+ entityId: counterEntityId,
282
+ messageKey,
283
+ };
284
+ params.variants = choice;
285
+ }
286
+ }
287
+ return { template, params };
288
+ }
289
+ /**
290
+ * ADR-255 D6: register one evaluator per overridden standard-action message,
291
+ * on the SAME phrasebook resolution seam (`phrasebook.template.<id>`) the
292
+ * engine consults before the platform default — so an `override message`
293
+ * sets the story-wide baseline (with full strategy/cycling parity) while a
294
+ * per-entity phrase or on-clause refusal, which emit their own message ids,
295
+ * still win. The alias is resolved to its dotted `if.action.*` id here, on
296
+ * the loader side (Interface Contract 3); the alias never reaches the engine.
297
+ */
298
+ registerMessageOverrideEvaluators(world) {
299
+ const table = this.ir.messageOverrides.locales[this.ir.messageOverrides.defaultLocale] ?? {};
300
+ for (const [alias, entry] of Object.entries(table)) {
301
+ const messageId = (0, message_alias_map_js_1.aliasToActionMessageId)(alias);
302
+ if (!messageId)
303
+ continue; // analyzer already rejected unknown aliases
304
+ world.registerEvaluator((0, engine_1.phrasebookTemplateKey)(messageId), () => {
305
+ if (entry.condition && !this.evaluator.evalCondition(entry.condition, { world }))
306
+ return undefined;
307
+ const { template, params } = this.derivePhraseTemplate(entry, 'message-override', messageId);
308
+ return { book: 'message-override', key: messageId, template, ...(Object.keys(params).length > 0 ? { params } : {}) };
309
+ });
310
+ }
311
+ }
312
+ /**
313
+ * ADR-240 D2/D3: register every derived property as a named world-evaluator.
314
+ * `dark while` rooms register on `dark.<roomId>`; EVERY blocked exit —
315
+ * conditional or not (a constant-true predicate) — registers on
316
+ * `exit.blocked.<roomId>.<direction>`, with its refusal message on
317
+ * `exit.message.*` resolved AT REFUSAL TIME (phrase strategies vary per
318
+ * attempt). Registration is idempotent per world; re-binding re-registers.
319
+ */
320
+ registerDerivedEvaluators(world) {
321
+ for (const { entity, condition } of this.derivedDarkRooms()) {
322
+ const worldId = this.host.entityId(entity.id);
323
+ if (!worldId)
324
+ continue;
325
+ world.registerEvaluator((0, world_model_1.darkKey)(worldId), (w) => this.evaluator.evalCondition(condition, { world: w }));
326
+ }
327
+ for (const irEntity of this.ir.entities) {
328
+ if (irEntity.blockedExits.length === 0)
329
+ continue;
330
+ const worldId = this.host.entityId(irEntity.id);
331
+ if (!worldId)
332
+ continue;
333
+ for (const blocked of irEntity.blockedExits) {
334
+ const direction = world_model_1.Direction[blocked.direction.toUpperCase()];
335
+ if (!direction)
336
+ continue;
337
+ const condition = blocked.condition;
338
+ world.registerEvaluator((0, stdlib_1.exitBlockedKey)(worldId, direction), condition
339
+ ? (w) => this.evaluator.evalCondition(condition, { world: w, it: irEntity.id })
340
+ : () => true);
341
+ world.registerEvaluator((0, stdlib_1.exitMessageKey)(worldId, direction), (w) => this.blockedPhraseText(blocked.phraseKey, w));
131
342
  }
132
343
  }
133
344
  }
@@ -159,28 +370,32 @@ class ChordRuntime {
159
370
  deadGerundError(clause) {
160
371
  const phrase = `${clause.clauseKind} ${clause.action} it`;
161
372
  if (clause.action === 'lowering' || clause.action === 'raising') {
162
- return new errors_1.LoadError(`\`${phrase}\` — \`${clause.action}\` is a full-delegation capability action by design (ADR-118): the standard action never consults interceptors. Use a capability behavior or a Chord dispatch action (\`define action ${clause.action}\`) instead.`, clause.span);
373
+ return new errors_js_1.LoadError(`\`${phrase}\` — \`${clause.action}\` is a full-delegation capability action by design (ADR-118): the standard action never consults interceptors. Use a capability behavior or a Chord dispatch action (\`define action ${clause.action}\`) instead.`, clause.span);
163
374
  }
164
- return new errors_1.LoadError(`\`${phrase}\` — no standard action consults \`if.action.${clause.action}\`, so this clause would never fire. Check the action word's spelling, or create the verb with \`define action ${clause.action}\`.`, clause.span);
375
+ return new errors_js_1.LoadError(`\`${phrase}\` — no standard action consults \`if.action.${clause.action}\`, so this clause would never fire. Check the action word's spelling, or create the verb with \`define action ${clause.action}\`.`, clause.span);
165
376
  }
166
377
  // ---------------------------------------------------------- event clauses
167
378
  /**
168
- * Bind an event clause (`after entering it` on a room) to its trigger
169
- * event per the selector contract — the ownership package's replacement
170
- * for floating `when` rules: the same firing semantics, owned by the
171
- * entity the event is about.
379
+ * Bind an event clause (`after entering it` on a room or region) to its
380
+ * trigger event per the selector contract — the ownership package's
381
+ * replacement for floating `when` rules: the same firing semantics,
382
+ * owned by the entity the event is about.
172
383
  */
173
- bindEventClause(world, entity, clause, clauseIndex) {
174
- const trigger = event_contract_1.EVENT_TRIGGERS[clause.action];
384
+ bindEventClause(world, entity, clause, clauseIndex, trigger) {
175
385
  const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
176
386
  world.chainEvent(trigger, (event, w) => this.fireEventClause(entity, clause, key, event, w), { key });
177
387
  }
388
+ /** The clause's trigger event type by owner kind, or undefined for non-event clauses. */
389
+ eventTriggerFor(entity, clause) {
390
+ const isRegionOwner = entity.kinds.some((k) => k.name === 'region');
391
+ return isRegionOwner ? event_contract_js_1.REGION_EVENT_TRIGGERS[clause.action] : event_contract_js_1.EVENT_TRIGGERS[clause.action];
392
+ }
178
393
  /** Test/debug entry: run every event clause bound to this event type. */
179
394
  fireEventClauses(world, event) {
180
395
  const out = [];
181
396
  for (const entity of this.ir.entities) {
182
397
  entity.onClauses.forEach((clause, clauseIndex) => {
183
- if (clause.binding === 'every-turn' || event_contract_1.EVENT_TRIGGERS[clause.action] !== event.type)
398
+ if (clause.binding === 'every-turn' || this.eventTriggerFor(entity, clause) !== event.type)
184
399
  return;
185
400
  const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
186
401
  const produced = this.fireEventClause(entity, clause, key, event, world);
@@ -191,15 +406,24 @@ class ChordRuntime {
191
406
  return out;
192
407
  }
193
408
  fireEventClause(entity, clause, key, event, world) {
194
- // The clause is about its owner: `after entering it` fires when the
195
- // movement's destination IS the ownerread through the AC-9 payload
196
- // guard, never a blind cast (the stdlib event is a foreign surface).
197
- if (clause.action === 'entering' && (0, event_contract_1.enteringDestination)(event.data) !== this.host.entityId(entity.id))
409
+ // The clause is about its owner. Region owners (ADR-236 D6): the
410
+ // crossing event names which boundary was crossed fire only for this
411
+ // region's own boundary (the emitter's getRegionCrossings already made
412
+ // parent reactions crossing-accurate; no transitive widening here).
413
+ if (entity.kinds.some((k) => k.name === 'region')) {
414
+ if ((0, event_contract_js_1.crossingRegionId)(event.data) !== this.host.entityId(entity.id))
415
+ return null;
416
+ }
417
+ else if (clause.action === 'entering' && (0, event_contract_js_1.enteringDestination)(event.data) !== this.host.entityId(entity.id)) {
418
+ // Room/enterable owners: `after entering it` fires when the
419
+ // movement's destination IS the owner — read through the AC-9 payload
420
+ // guard, never a blind cast (the stdlib event is a foreign surface).
198
421
  return null;
422
+ }
199
423
  const ctx = { world, it: entity.id };
200
424
  if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
201
425
  return null;
202
- const occKey = state_keys_1.CHORD_OCCURRENCE_PREFIX + key;
426
+ const occKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + key;
203
427
  const occurrence = (world.getStateValue(occKey) ?? 0) + 1;
204
428
  if (clause.once && occurrence > 1)
205
429
  return null; // `, once` — one lifetime firing (D5)
@@ -213,10 +437,10 @@ class ChordRuntime {
213
437
  prepareOnClauseTarget(world, entity, clause) {
214
438
  const worldId = this.host.entityId(entity.id);
215
439
  if (!worldId)
216
- throw new errors_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, clause.span);
440
+ throw new errors_js_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, clause.span);
217
441
  const target = world.getEntity(worldId);
218
442
  if (!target)
219
- throw new errors_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, clause.span);
443
+ throw new errors_js_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, clause.span);
220
444
  if (!target.has(ChordBehaviorTrait.type)) {
221
445
  target.add(new ChordBehaviorTrait());
222
446
  }
@@ -225,17 +449,106 @@ class ChordRuntime {
225
449
  target.add(new world_model_1.ReadableTrait({ text: '' }));
226
450
  }
227
451
  }
452
+ /** Mark a topic-table owner so interceptor resolution finds it (no clause needed). */
453
+ prepareTopicTarget(world, entity) {
454
+ const worldId = this.host.entityId(entity.id);
455
+ if (!worldId)
456
+ throw new errors_js_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, entity.span);
457
+ const target = world.getEntity(worldId);
458
+ if (!target)
459
+ throw new errors_js_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, entity.span);
460
+ if (!target.has(ChordBehaviorTrait.type)) {
461
+ target.add(new ChordBehaviorTrait());
462
+ }
463
+ }
228
464
  /**
229
- * One interceptor per action: each hook forwards to the clause whose
230
- * entity is the action's target (per-clause interceptors keep their own
231
- * occurrence keys and decision snapshots).
465
+ * Per-clause consultation state. Two live arms on one owner (ratchet D3's
466
+ * `on` + `after` pair) share ONE InterceptorSharedData bag per firing —
467
+ * each clause keeps its skip/occurrence/decision state in its own
468
+ * namespaced sub-bag so the arms never clobber each other.
232
469
  */
233
- buildDispatchingInterceptor(clauses) {
470
+ clauseBag(data, ns) {
471
+ const key = `chord.arm.${ns}`;
472
+ let bag = data[key];
473
+ if (!bag) {
474
+ bag = {};
475
+ data[key] = bag;
476
+ }
477
+ return bag;
478
+ }
479
+ /**
480
+ * Merge one owner's clause interceptors into a single arm, in declaration
481
+ * order (the ratchet D3 contract, previously broken by first-match arm
482
+ * routing — an `on`/`after` pair on the same owner+gerund silently
483
+ * shadowed the second clause): the first refusal wins preValidate; every
484
+ * arm's postValidate/postExecute runs (own namespaced state); postReport
485
+ * merges — the first `on` override wins (only `on` clauses override),
486
+ * every arm's emits APPEND (the `after` half of D3).
487
+ */
488
+ mergeArms(arms) {
489
+ if (arms.length === 1)
490
+ return arms[0];
491
+ return {
492
+ preValidate(target, world, actorId, data) {
493
+ for (const arm of arms) {
494
+ const veto = arm.preValidate?.(target, world, actorId, data);
495
+ if (veto)
496
+ return veto;
497
+ }
498
+ return null;
499
+ },
500
+ postValidate(target, world, actorId, data) {
501
+ for (const arm of arms)
502
+ arm.postValidate?.(target, world, actorId, data);
503
+ return null;
504
+ },
505
+ postExecute(target, world, actorId, data) {
506
+ for (const arm of arms)
507
+ arm.postExecute?.(target, world, actorId, data);
508
+ },
509
+ postReport(target, world, actorId, data) {
510
+ const merged = {};
511
+ const emit = [];
512
+ for (const arm of arms) {
513
+ const result = arm.postReport?.(target, world, actorId, data) ?? {};
514
+ if (result.override && !merged.override)
515
+ merged.override = result.override;
516
+ if (result.emit)
517
+ emit.push(...result.emit);
518
+ }
519
+ if (emit.length)
520
+ merged.emit = emit;
521
+ return merged;
522
+ },
523
+ };
524
+ }
525
+ /**
526
+ * One interceptor per action: each hook forwards to the arm whose entity
527
+ * is the action's target. An owner's arm is the D3-merged composite of
528
+ * ALL its clauses for this action (each clause keeps its own namespaced
529
+ * occurrence keys and decision snapshots). On the topic gerunds
530
+ * (asking/telling, ADR-239) a table owner's arm consults its declared
531
+ * topic table first; the merged clause composite serves as the
532
+ * catch-all, firing only on a table miss (D5).
533
+ */
534
+ buildDispatchingInterceptor(action, clauses) {
234
535
  const runtime = this;
235
- const arms = clauses.map(({ entity, clause }) => ({
236
- entity,
237
- interceptor: this.buildInterceptor(entity, clause),
238
- }));
536
+ const isTopicAction = TOPIC_GERUNDS.includes(action);
537
+ const byEntity = new Map();
538
+ for (const { entity, clause } of clauses) {
539
+ const group = byEntity.get(entity.id) ?? { entity, entityClauses: [] };
540
+ if (clause)
541
+ group.entityClauses.push(clause);
542
+ byEntity.set(entity.id, group);
543
+ }
544
+ const arms = [...byEntity.values()].map(({ entity, entityClauses }) => {
545
+ const built = entityClauses.map((clause, index) => this.buildInterceptor(entity, clause, `${entity.id}.${action}.${clause.clauseKind}.${index}`));
546
+ const catchAll = built.length ? this.mergeArms(built) : undefined;
547
+ const interceptor = isTopicAction && (entity.topics ?? []).length
548
+ ? this.buildTopicArm(entity, catchAll)
549
+ : catchAll ?? {};
550
+ return { entity, interceptor };
551
+ });
239
552
  const armFor = (target) => arms.find((a) => runtime.host.entityId(a.entity.id) === target.id)?.interceptor;
240
553
  return {
241
554
  preValidate(target, world, actorId, data) {
@@ -259,15 +572,15 @@ class ChordRuntime {
259
572
  * win/lose → postReport. An `on` clause's first phrase OVERRIDES the
260
573
  * primary message; an `after` clause's phrases APPEND (D3).
261
574
  */
262
- buildInterceptor(entity, clause) {
575
+ buildInterceptor(entity, clause, ns) {
263
576
  const runtime = this;
264
577
  const ownWorldId = () => runtime.host.entityId(entity.id);
265
- const skipped = (data) => data.chordSkip === true;
266
- const occurrenceKey = state_keys_1.CHORD_OCCURRENCE_PREFIX + `on.${entity.id}.${clause.action}`;
578
+ const occurrenceKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + `on.${ns}`;
267
579
  return {
268
580
  preValidate(target, world, _actorId, data) {
269
581
  if (target.id !== ownWorldId() || clause.clauseKind === 'after')
270
582
  return null;
583
+ const bag = runtime.clauseBag(data, ns);
271
584
  const ctx = { world, it: entity.id };
272
585
  // D8 (ADR-228): the `while` gate is evaluated once per firing, at
273
586
  // validate time, BEFORE findRefusal — a gated-out clause sits out
@@ -275,13 +588,13 @@ class ChordRuntime {
275
588
  // evaluate the gate: no mutation occurs between them within one
276
589
  // action, so the answers cannot differ. Do not move this evaluation.
277
590
  if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
278
- data.chordSkip = true;
591
+ bag.skip = true;
279
592
  return null;
280
593
  }
281
594
  // `, once`: a clause that has already fired keeps its refusal out
282
595
  // too (peek only — the occurrence bump stays in postValidate).
283
596
  if (clause.once && (world.getStateValue(occurrenceKey) ?? 0) >= 1) {
284
- data.chordSkip = true;
597
+ bag.skip = true;
285
598
  return null;
286
599
  }
287
600
  const refusal = runtime.findRefusal(clause.body, ctx);
@@ -290,34 +603,36 @@ class ChordRuntime {
290
603
  postValidate(target, world, _actorId, data) {
291
604
  if (target.id !== ownWorldId())
292
605
  return null;
606
+ const bag = runtime.clauseBag(data, ns);
293
607
  const ctx = { world, it: entity.id };
294
608
  // D8: same gate, same evaluation point (see preValidate).
295
609
  if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
296
- data.chordSkip = true; // `while <cond>` gate — clause sits out this firing
610
+ bag.skip = true; // `while <cond>` gate — clause sits out this firing
297
611
  return null;
298
612
  }
299
- const key = occurrenceKey;
300
- const occurrence = (world.getStateValue(key) ?? 0) + 1;
613
+ const occurrence = (world.getStateValue(occurrenceKey) ?? 0) + 1;
301
614
  if (clause.once && occurrence > 1) {
302
- data.chordSkip = true; // `, once` — one lifetime firing (D5)
615
+ bag.skip = true; // `, once` — one lifetime firing (D5)
303
616
  return null;
304
617
  }
305
- world.setStateValue(key, occurrence);
618
+ world.setStateValue(occurrenceKey, occurrence);
306
619
  ctx.occurrence = occurrence;
307
- data.chordOccurrence = occurrence;
308
- data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
620
+ bag.occurrence = occurrence;
621
+ bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
309
622
  return null;
310
623
  },
311
624
  postExecute(target, world, _actorId, data) {
312
- if (target.id !== ownWorldId() || skipped(data))
625
+ const bag = runtime.clauseBag(data, ns);
626
+ if (target.id !== ownWorldId() || bag.skip === true)
313
627
  return;
314
- const ctx = runtime.restoreCtx(world, entity.id, data);
628
+ const ctx = runtime.restoreCtx(world, entity.id, bag);
315
629
  runtime.execStatements(clause.body, ctx, 'mutations');
316
630
  },
317
631
  postReport(target, world, _actorId, data) {
318
- if (target.id !== ownWorldId() || skipped(data))
632
+ const bag = runtime.clauseBag(data, ns);
633
+ if (target.id !== ownWorldId() || bag.skip === true)
319
634
  return {};
320
- const ctx = runtime.restoreCtx(world, entity.id, data);
635
+ const ctx = runtime.restoreCtx(world, entity.id, bag);
321
636
  const reports = runtime.execStatements(clause.body, ctx, 'reports');
322
637
  const result = {};
323
638
  const emit = [];
@@ -339,12 +654,110 @@ class ChordRuntime {
339
654
  },
340
655
  };
341
656
  }
342
- restoreCtx(world, itIrId, data) {
657
+ restoreCtx(world, itIrId, bag) {
343
658
  return {
344
659
  world,
345
660
  it: itIrId,
346
- occurrence: data.chordOccurrence,
347
- decisions: data.chordDecisions,
661
+ occurrence: bag.occurrence,
662
+ decisions: bag.decisions,
663
+ };
664
+ }
665
+ // ------------------------------------------------- topic tables (ADR-239)
666
+ /**
667
+ * Runtime dispatch for one topic-table owner (ADR-239 D4/D5): normalized
668
+ * whole-topic lookup against the declared rows — entity tier first (the
669
+ * platform's quiet `topicEntityId` resolution), then free-text tier
670
+ * (primary or declared alias; the SAME normalizeTopic the analyzer's
671
+ * overlap gates used — one implementation, imported from chord). A hit
672
+ * runs the matched ROW's body exactly like a one-clause `on` firing
673
+ * (its first phrase OVERRIDES the primary message; the catch-all never
674
+ * runs — suppression, not append). A miss falls to the owner's
675
+ * catch-all clause when one is declared; with none, `{}` leaves the
676
+ * action's unconditional unknown_topic/not_interested default standing.
677
+ * The asked topic reaches `data` via the lifecycle seedData hook.
678
+ */
679
+ buildTopicArm(entity, catchAll) {
680
+ const runtime = this;
681
+ const rows = entity.topics ?? [];
682
+ /** Match once per firing; memoized on the consultation's sharedData. */
683
+ const rowIndexFor = (data) => {
684
+ if (typeof data.chordTopicRow === 'number')
685
+ return data.chordTopicRow;
686
+ const askedEntity = typeof data.topicEntityId === 'string' ? data.topicEntityId : null;
687
+ const askedText = typeof data.topic === 'string' ? (0, chord_1.normalizeTopic)(data.topic) : null;
688
+ let index = -1;
689
+ if (askedEntity !== null) {
690
+ index = rows.findIndex((r) => r.filter.kind === 'entity' && runtime.host.entityId(r.filter.id) === askedEntity);
691
+ }
692
+ if (index === -1 && askedText !== null && askedText !== '') {
693
+ index = rows.findIndex((r) => r.filter.kind === 'text' &&
694
+ ((0, chord_1.normalizeTopic)(r.filter.primary) === askedText || r.filter.aliases.some((a) => (0, chord_1.normalizeTopic)(a) === askedText)));
695
+ }
696
+ data.chordTopicRow = index;
697
+ return index;
698
+ };
699
+ // One occurrence namespace per ROW, shared across ask and tell (D1 —
700
+ // one table serves both): a row-body `first time` ordinal counts
701
+ // deliveries of that response, however it was reached.
702
+ const occurrenceKeyOf = (rowIndex) => `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}topic.${entity.id}.${rowIndex}`;
703
+ return {
704
+ preValidate(target, world, actorId, data) {
705
+ const row = rows[rowIndexFor(data)];
706
+ if (!row)
707
+ return catchAll?.preValidate?.(target, world, actorId, data) ?? null;
708
+ const ctx = { world, it: entity.id };
709
+ const refusal = runtime.findRefusal(row.body, ctx);
710
+ return refusal ? { valid: false, error: refusal } : null;
711
+ },
712
+ postValidate(target, world, actorId, data) {
713
+ const index = rowIndexFor(data);
714
+ const row = rows[index];
715
+ if (!row)
716
+ return catchAll?.postValidate?.(target, world, actorId, data) ?? null;
717
+ const bag = runtime.clauseBag(data, `topic.${entity.id}`);
718
+ const ctx = { world, it: entity.id };
719
+ const key = occurrenceKeyOf(index);
720
+ const occurrence = (world.getStateValue(key) ?? 0) + 1;
721
+ world.setStateValue(key, occurrence);
722
+ ctx.occurrence = occurrence;
723
+ bag.occurrence = occurrence;
724
+ bag.decisions = runtime.snapshotDecisions(row.body, ctx);
725
+ return null;
726
+ },
727
+ postExecute(target, world, actorId, data) {
728
+ const row = rows[rowIndexFor(data)];
729
+ if (!row) {
730
+ catchAll?.postExecute?.(target, world, actorId, data);
731
+ return;
732
+ }
733
+ const ctx = runtime.restoreCtx(world, entity.id, runtime.clauseBag(data, `topic.${entity.id}`));
734
+ runtime.execStatements(row.body, ctx, 'mutations');
735
+ },
736
+ postReport(target, world, actorId, data) {
737
+ const row = rows[rowIndexFor(data)];
738
+ if (!row)
739
+ return catchAll?.postReport?.(target, world, actorId, data) ?? {};
740
+ const ctx = runtime.restoreCtx(world, entity.id, runtime.clauseBag(data, `topic.${entity.id}`));
741
+ const reports = runtime.execStatements(row.body, ctx, 'reports');
742
+ const result = {};
743
+ const emit = [];
744
+ for (const event of reports) {
745
+ const payload = (event.data ?? {});
746
+ if (event.type === 'chord.phrase' && !result.override) {
747
+ // A hit fully owns the response (D5) — override, never append.
748
+ result.override = {
749
+ messageId: String(payload.messageId),
750
+ params: payload.params ?? {},
751
+ };
752
+ }
753
+ else {
754
+ emit.push({ type: event.type, payload });
755
+ }
756
+ }
757
+ if (emit.length)
758
+ result.emit = emit;
759
+ return result;
760
+ },
348
761
  };
349
762
  }
350
763
  // ------------------------------------------- dispatch verbs (Phase B, §5.4)
@@ -352,7 +765,7 @@ class ChordRuntime {
352
765
  writeChordTraitField(world, worldId, field, value, span) {
353
766
  const entity = world.getEntity(worldId);
354
767
  for (const trait of entity?.traits.values() ?? []) {
355
- if (!trait.type.startsWith(state_keys_1.CHORD_TRAIT_PREFIX))
768
+ if (!trait.type.startsWith(state_keys_js_1.CHORD_TRAIT_PREFIX))
356
769
  continue;
357
770
  const record = trait;
358
771
  if (field in record) {
@@ -360,7 +773,7 @@ class ChordRuntime {
360
773
  return;
361
774
  }
362
775
  }
363
- throw new errors_1.LoadError(`No trait on this entity carries the field \`${field}\`.`, span);
776
+ throw new errors_js_1.LoadError(`No trait on this entity carries the field \`${field}\`.`, span);
364
777
  }
365
778
  /**
366
779
  * A `define trait` clause on a dispatch verb → CapabilityBehavior
@@ -390,7 +803,7 @@ class ChordRuntime {
390
803
  data.chordSkip = true;
391
804
  return { valid: true };
392
805
  }
393
- const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${traitName}.${clause.action}.${runtime.host.irIdOf(entity.id)}`;
806
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${traitName}.${clause.action}.${runtime.host.irIdOf(entity.id)}`;
394
807
  const occurrence = (world.getStateValue(key) ?? 0) + 1;
395
808
  if (clause.once && occurrence > 1) {
396
809
  data.chordSkip = true; // `, once` — one lifetime firing (D5)
@@ -427,11 +840,10 @@ class ChordRuntime {
427
840
  * ActionInterceptor registered under the trait type (ADR-118 resolves it
428
841
  * for every entity carrying the trait).
429
842
  */
430
- buildTraitInterceptor(clause) {
843
+ buildTraitInterceptor(clause, ns) {
431
844
  const runtime = this;
432
845
  const itOf = (target) => runtime.host.irIdOf(target.id) ?? target.id;
433
- const skipped = (data) => data.chordSkip === true;
434
- const occurrenceKeyOf = (target) => `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${clause.action}.${itOf(target)}`;
846
+ const occurrenceKeyOf = (target) => `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${ns}.${itOf(target)}`;
435
847
  return {
436
848
  preValidate(target, world, _actorId, data) {
437
849
  if (clause.clauseKind === 'after')
@@ -442,52 +854,58 @@ class ChordRuntime {
442
854
  // entirely, refusals included. preValidate and postValidate may both
443
855
  // evaluate the gate: no mutation occurs between them within one
444
856
  // action, so the answers cannot differ. Do not move this evaluation.
857
+ const bag = runtime.clauseBag(data, ns);
445
858
  if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
446
- data.chordSkip = true;
859
+ bag.skip = true;
447
860
  return null;
448
861
  }
449
862
  // `, once`: a clause that has already fired keeps its refusal out
450
863
  // too (peek only — the occurrence bump stays in postValidate).
451
864
  if (clause.once && (world.getStateValue(occurrenceKeyOf(target)) ?? 0) >= 1) {
452
- data.chordSkip = true;
865
+ bag.skip = true;
453
866
  return null;
454
867
  }
455
868
  const refusal = runtime.findRefusal(clause.body, ctx);
456
869
  return refusal ? { valid: false, error: refusal } : null;
457
870
  },
458
871
  postValidate(target, world, _actorId, data) {
872
+ const bag = runtime.clauseBag(data, ns);
459
873
  const ctx = { world, it: itOf(target) };
460
874
  // D8: same gate, same evaluation point (see preValidate).
461
875
  if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
462
- data.chordSkip = true; // `while <cond>` gate — clause sits out this firing
876
+ bag.skip = true; // `while <cond>` gate — clause sits out this firing
463
877
  return null;
464
878
  }
465
879
  const key = occurrenceKeyOf(target);
466
880
  const occurrence = (world.getStateValue(key) ?? 0) + 1;
467
881
  if (clause.once && occurrence > 1) {
468
- data.chordSkip = true; // `, once` — one lifetime firing (D5)
882
+ bag.skip = true; // `, once` — one lifetime firing (D5)
469
883
  return null;
470
884
  }
471
885
  world.setStateValue(key, occurrence);
472
886
  ctx.occurrence = occurrence;
473
- data.chordOccurrence = occurrence;
474
- data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
887
+ bag.occurrence = occurrence;
888
+ bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
475
889
  return null;
476
890
  },
477
891
  postExecute(target, world, _actorId, data) {
478
- if (skipped(data))
892
+ const bag = runtime.clauseBag(data, ns);
893
+ if (bag.skip === true)
479
894
  return;
480
- runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'mutations');
895
+ runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'mutations');
481
896
  },
482
897
  postReport(target, world, _actorId, data) {
483
- if (skipped(data))
898
+ const bag = runtime.clauseBag(data, ns);
899
+ if (bag.skip === true)
484
900
  return {};
485
- const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'reports');
901
+ const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'reports');
486
902
  const result = {};
487
903
  const emit = [];
488
904
  for (const event of reports) {
489
905
  const payload = (event.data ?? {});
490
- if (event.type === 'chord.phrase' && !result.override) {
906
+ // Only `on` clauses override the primary message; `after` phrases
907
+ // APPEND (ratchet D3 — mirrors the entity interceptor's guard).
908
+ if (clause.clauseKind === 'on' && event.type === 'chord.phrase' && !result.override) {
491
909
  result.override = { messageId: String(payload.messageId), params: payload.params ?? {} };
492
910
  }
493
911
  else {
@@ -622,6 +1040,14 @@ class ChordRuntime {
622
1040
  },
623
1041
  blocked(context, result) {
624
1042
  const key = result.error ?? def.otherwise ?? 'cant';
1043
+ // Platform default (Phase 8 #13): `'cant'` is the built-in fallback
1044
+ // key for an action with no authored `otherwise`/refusal — no story
1045
+ // phrase exists for it, and phraseEvent would throw a LoadError at
1046
+ // emit time. Render the platform's generic refusal instead
1047
+ // (lang-en-us `scope.out_of_scope`: "You can't do that.").
1048
+ if (key === 'cant') {
1049
+ return [context.event('action.blocked', { messageId: 'scope.out_of_scope', reason: 'cant' })];
1050
+ }
625
1051
  const event = runtime.phraseEvent(key, { world: context.world });
626
1052
  return [context.event(event.type, (event.data ?? {}))];
627
1053
  },
@@ -661,7 +1087,7 @@ class ChordRuntime {
661
1087
  // <state>` on a state anchor (ratchet D10). Pointer and last-fired
662
1088
  // turn live in world state — save/restore covers progression.
663
1089
  const slug = sequence.name.replace(/\s+/g, '-');
664
- const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
1090
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
665
1091
  const firedKey = `${key}.turn`;
666
1092
  const stepReady = (step, world, turn) => {
667
1093
  switch (step.timing) {
@@ -675,9 +1101,9 @@ class ChordRuntime {
675
1101
  if (!step.anchor)
676
1102
  return false;
677
1103
  if (step.anchor.owner === 'story') {
678
- return world.getStateValue(state_keys_1.CHORD_STORY_STATE_KEY) === step.anchor.state;
1104
+ return world.getStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY) === step.anchor.state;
679
1105
  }
680
- return world.getStateValue(state_keys_1.CHORD_STATE_PREFIX + step.anchor.owner) === step.anchor.state;
1106
+ return world.getStateValue(state_keys_js_1.CHORD_STATE_PREFIX + step.anchor.owner) === step.anchor.state;
681
1107
  }
682
1108
  }
683
1109
  };
@@ -705,7 +1131,7 @@ class ChordRuntime {
705
1131
  irEntity.onClauses.forEach((clause, clauseIndex) => {
706
1132
  if (clause.binding !== 'every-turn')
707
1133
  return;
708
- const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}entity-turn.${irEntity.id}.${clauseIndex}`;
1134
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}entity-turn.${irEntity.id}.${clauseIndex}`;
709
1135
  daemons.push({
710
1136
  id: `chord.entity-turn.${irEntity.id}.${clauseIndex}`,
711
1137
  name: `on every turn (${irEntity.id})`,
@@ -730,6 +1156,31 @@ class ChordRuntime {
730
1156
  });
731
1157
  });
732
1158
  });
1159
+ // Story-owned every-turn clauses (`on every turn` in the story header
1160
+ // body — ADR-236 D7, ratchet R4): one daemon per clause with NO
1161
+ // presence gate — the story is everywhere ("a background clock for the
1162
+ // whole game"); narration broadcasts. `it` never appears in the body
1163
+ // (the analyzer's story-clause-it gate refused it at compile).
1164
+ (this.ir.story.onClauses ?? []).forEach((clause, clauseIndex) => {
1165
+ if (clause.binding !== 'every-turn')
1166
+ return;
1167
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}story-turn.${clauseIndex}`;
1168
+ daemons.push({
1169
+ id: `chord.story-turn.${clauseIndex}`,
1170
+ name: 'on every turn (story)',
1171
+ run: (ctx) => {
1172
+ const evalCtx = { world: ctx.world };
1173
+ if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
1174
+ return [];
1175
+ const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
1176
+ if (clause.once && fired > 1)
1177
+ return []; // `, once` (D5)
1178
+ ctx.world.setStateValue(key, fired);
1179
+ evalCtx.occurrence = fired;
1180
+ return this.narrated(this.execStatements(clause.body, evalCtx));
1181
+ },
1182
+ });
1183
+ });
733
1184
  // Every-turn trait clauses (`on every turn while …[, once]`): one
734
1185
  // daemon per clause, evaluated per entity carrying the trait. The
735
1186
  // composition condition (`chatty while not after-hours`) gates per
@@ -738,7 +1189,7 @@ class ChordRuntime {
738
1189
  trait.onClauses.forEach((clause, clauseIndex) => {
739
1190
  if (clause.binding !== 'every-turn')
740
1191
  return;
741
- const traitType = state_keys_1.CHORD_TRAIT_PREFIX + trait.name;
1192
+ const traitType = state_keys_js_1.CHORD_TRAIT_PREFIX + trait.name;
742
1193
  daemons.push({
743
1194
  id: `chord.trait-turn.${trait.name}.${clauseIndex}`,
744
1195
  name: `on every turn (${trait.name})`,
@@ -761,7 +1212,7 @@ class ChordRuntime {
761
1212
  continue;
762
1213
  if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
763
1214
  continue;
764
- const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait-turn.${trait.name}.${clauseIndex}.${irEntity.id}`;
1215
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait-turn.${trait.name}.${clauseIndex}.${irEntity.id}`;
765
1216
  const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
766
1217
  if (clause.once && fired > 1)
767
1218
  continue; // `, once` (D5)
@@ -794,11 +1245,23 @@ class ChordRuntime {
794
1245
  narrated(events) {
795
1246
  return events.map((e) => ({ ...e, narrate: true }));
796
1247
  }
1248
+ /**
1249
+ * Execute a machine body (`on enter`/`on exit`/transition effects,
1250
+ * ADR-215 state-machines depth) — story-owned: no `it` (compile-gated),
1251
+ * narration broadcasts like any story-owned surface.
1252
+ * @param statements the resolved IR statement tree
1253
+ * @param world the live world the effect runs against
1254
+ */
1255
+ execMachineBody(statements, world) {
1256
+ return this.narrated(this.execStatements(statements, { world }));
1257
+ }
797
1258
  /**
798
1259
  * Decision 10 presence semantics: the player shares the owner's location.
799
- * A room owner means the player is IN that room; for anything else the
800
- * two share a containing room (same co-location rule as can-see/can-reach
801
- * presence, not sight, so the snake speaks in darkness).
1260
+ * A room owner means the player is IN that room; a region owner "is" at
1261
+ * every member room presence is `isInRegion(player, region)`, transitive
1262
+ * through nesting (ADR-236 D4); for anything else the two share a
1263
+ * containing room (same co-location rule as can-see/can-reach — presence,
1264
+ * not sight, so the snake speaks in darkness).
802
1265
  */
803
1266
  playerPresentAt(world, irEntityId) {
804
1267
  const ownerId = this.host.entityId(irEntityId);
@@ -807,8 +1270,10 @@ class ChordRuntime {
807
1270
  return false;
808
1271
  if (ownerId === playerId)
809
1272
  return true;
810
- const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
811
1273
  const owner = world.getEntity(ownerId);
1274
+ if (owner?.has(world_model_1.TraitType.REGION))
1275
+ return world.isInRegion(playerId, ownerId);
1276
+ const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
812
1277
  if (owner?.has(world_model_1.TraitType.ROOM))
813
1278
  return playerRoom === ownerId;
814
1279
  const ownerRoom = world.getContainingRoom(ownerId)?.id ?? world.getLocation(ownerId);
@@ -834,7 +1299,7 @@ class ChordRuntime {
834
1299
  const ctx = { world, it: targetIrId };
835
1300
  if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
836
1301
  return;
837
- const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}after.${irEntity.id}.${actionName}.${clauseIndex}`;
1302
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}after.${irEntity.id}.${actionName}.${clauseIndex}`;
838
1303
  const occurrence = (world.getStateValue(key) ?? 0) + 1;
839
1304
  if (clause.once && occurrence > 1)
840
1305
  return; // `, once` (D5)
@@ -856,49 +1321,53 @@ class ChordRuntime {
856
1321
  }
857
1322
  return out;
858
1323
  }
1324
+ // ADR-240 D4: `recomputeDerived` and its trigger wiring are DELETED — the
1325
+ // registered evaluators above are consulted live at every read; there is
1326
+ // no cached derivation left to refresh.
859
1327
  /**
860
- * Re-evaluate derived properties: `dark while` into `RoomTrait.requiresLight`,
861
- * and `is blocked while <cond>` into blockedExits (block/unblock per the
862
- * condition's current truth grammar log 2026-07-10).
1328
+ * Blocked-exit refusal text, resolved AT REFUSAL TIME (ADR-240 D6): a
1329
+ * multi-variant phrase honors its strategy per attempt `randomly`
1330
+ * through the seeded story RNG, `cycling`/`stopping`/`first-time`
1331
+ * through a world-state counter, `sticky` through a stored pick — so
1332
+ * refusal text varies exactly as ADR-211 phrase semantics intend.
863
1333
  */
864
- recomputeDerived(world) {
865
- for (const { entity, condition } of this.derivedDarkRooms()) {
866
- const worldId = this.host.entityId(entity.id);
867
- const room = worldId ? world.getEntity(worldId)?.get(world_model_1.TraitType.ROOM) : undefined;
868
- if (!room)
869
- continue;
870
- room.requiresLight = this.evaluator.evalCondition(condition, { world });
871
- }
872
- for (const irEntity of this.ir.entities) {
873
- for (const blocked of irEntity.blockedExits) {
874
- if (!blocked.condition)
875
- continue;
876
- const worldId = this.host.entityId(irEntity.id);
877
- const room = worldId ? world.getEntity(worldId) : undefined;
878
- if (!room)
879
- continue;
880
- const direction = world_model_1.Direction[blocked.direction.toUpperCase()];
881
- if (!direction)
882
- continue;
883
- const holds = this.evaluator.evalCondition(blocked.condition, { world, it: irEntity.id });
884
- if (holds) {
885
- world_model_1.RoomBehavior.blockExit(room, direction, this.blockedPhraseText(blocked.phraseKey));
886
- }
887
- else {
888
- world_model_1.RoomBehavior.unblockExit(room, direction);
889
- }
1334
+ blockedPhraseText(key, world) {
1335
+ const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
1336
+ if (!phrase)
1337
+ return '';
1338
+ const variants = phrase.variants;
1339
+ if (variants.length <= 1)
1340
+ return variants[0]?.text ?? '';
1341
+ const stateKey = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}blocked.${key}`;
1342
+ switch (phrase.strategy) {
1343
+ case 'randomly':
1344
+ return variants[this.evaluator.pickIndex(variants.length, world)].text;
1345
+ case 'sticky': {
1346
+ const stored = world.getStateValue(stateKey);
1347
+ if (typeof stored === 'number')
1348
+ return variants[stored].text;
1349
+ const pick = this.evaluator.pickIndex(variants.length, world);
1350
+ world.setStateValue(stateKey, pick);
1351
+ return variants[pick].text;
1352
+ }
1353
+ case 'stopping': {
1354
+ const n = world.getStateValue(stateKey) ?? 0;
1355
+ world.setStateValue(stateKey, Math.min(n + 1, variants.length - 1));
1356
+ return variants[Math.min(n, variants.length - 1)].text;
1357
+ }
1358
+ case 'first-time': {
1359
+ const n = world.getStateValue(stateKey) ?? 0;
1360
+ world.setStateValue(stateKey, n + 1);
1361
+ return variants[n === 0 ? 0 : Math.min(1, variants.length - 1)].text;
1362
+ }
1363
+ case 'cycling':
1364
+ default: {
1365
+ const n = world.getStateValue(stateKey) ?? 0;
1366
+ world.setStateValue(stateKey, n + 1);
1367
+ return variants[n % variants.length].text;
890
1368
  }
891
1369
  }
892
1370
  }
893
- /** Single-variant text of a blocked-exit phrase (mirrors the loader's read). */
894
- blockedPhraseText(key) {
895
- const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
896
- return phrase?.variants[0]?.text ?? '';
897
- }
898
- /** True when any entity declares a conditional blocked exit. */
899
- hasConditionalBlockedExits() {
900
- return this.ir.entities.some((e) => e.blockedExits.some((b) => b.condition !== null));
901
- }
902
1371
  // ------------------------------------------------------------ statements
903
1372
  /**
904
1373
  * Execute a statement tree. `phase` narrows which leaves act:
@@ -919,8 +1388,15 @@ class ChordRuntime {
919
1388
  events.push(this.phraseEvent(stmt.phraseKey, ctx, stmt.params));
920
1389
  break;
921
1390
  case 'emit':
1391
+ // ADR-216: the payload evaluates live against the turn context —
1392
+ // literals as numbers/strings, value expressions through the
1393
+ // shared evaluator, arrays/objects recursively.
1394
+ // ADR-256: the Chord IR event id is dotless; translate it to the
1395
+ // platform runtime type here (media.* → dotted; author events pass
1396
+ // through). Not inside rawEvent — that also mints the internal
1397
+ // `chord.phrase` event, which must not be translated.
922
1398
  if (phase !== 'mutations' && whenHolds(stmt))
923
- events.push(this.rawEvent(stmt.event, {}));
1399
+ events.push(this.rawEvent((0, event_id_map_js_1.translateEventId)(stmt.event), this.emitPayload(stmt.payload, ctx)));
924
1400
  break;
925
1401
  case 'win':
926
1402
  case 'lose':
@@ -954,16 +1430,16 @@ class ChordRuntime {
954
1430
  if (phase !== 'reports' && whenHolds(stmt)) {
955
1431
  if (stmt.entity.kind === 'story') {
956
1432
  // `change the story to <state>` — the story object's phase (D2).
957
- 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);
958
- ctx.world.setStateValue(state_keys_1.CHORD_STORY_STATE_KEY, stmt.state);
1433
+ this.checkForwardMarch(this.ir.story.states, this.ir.story.reversible, ctx.world.getStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY), stmt.state, 'the story', stmt.span);
1434
+ ctx.world.setStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY, stmt.state);
959
1435
  }
960
1436
  else {
961
1437
  const irId = this.irIdOfValue(stmt.entity, ctx);
962
1438
  const set = this.stateSetOf(irId, stmt.state);
963
1439
  if (set) {
964
- this.checkForwardMarch(set.states, set.reversible, ctx.world.getStateValue(state_keys_1.CHORD_STATE_PREFIX + irId), stmt.state, irId, stmt.span);
1440
+ this.checkForwardMarch(set.states, set.reversible, ctx.world.getStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irId), stmt.state, irId, stmt.span);
965
1441
  }
966
- ctx.world.setStateValue(state_keys_1.CHORD_STATE_PREFIX + irId, stmt.state);
1442
+ ctx.world.setStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irId, stmt.state);
967
1443
  }
968
1444
  }
969
1445
  break;
@@ -997,7 +1473,7 @@ class ChordRuntime {
997
1473
  this.writeChordTraitField(ctx.world, baseId, stmt.target.field, value, stmt.span);
998
1474
  }
999
1475
  else {
1000
- throw new errors_1.LoadError('`set` targets a trait data field.', stmt.span);
1476
+ throw new errors_js_1.LoadError('`set` targets a trait data field.', stmt.span);
1001
1477
  }
1002
1478
  }
1003
1479
  break;
@@ -1008,12 +1484,12 @@ class ChordRuntime {
1008
1484
  // awards are no-ops and `, once` is automatic. Names arrive
1009
1485
  // owner-qualified from the analyzer (ratchet D12).
1010
1486
  if (stmt.expression.length !== 1) {
1011
- throw new errors_1.LoadError('Only `award <score-name>` is supported (expression awards are later scope).', stmt.span);
1487
+ throw new errors_js_1.LoadError('Only `award <score-name>` is supported (expression awards are later scope).', stmt.span);
1012
1488
  }
1013
1489
  const name = stmt.expression[0];
1014
1490
  const worth = this.scoreWorth.get(name);
1015
1491
  if (worth === undefined) {
1016
- throw new errors_1.LoadError(`\`${name}\` is not a declared score.`, stmt.span);
1492
+ throw new errors_js_1.LoadError(`\`${name}\` is not a declared score.`, stmt.span);
1017
1493
  }
1018
1494
  ctx.world.awardScore(name, worth, name);
1019
1495
  }
@@ -1139,7 +1615,7 @@ class ChordRuntime {
1139
1615
  const from = states.indexOf(current);
1140
1616
  const to = states.indexOf(target);
1141
1617
  if (from >= 0 && to >= 0 && to < from) {
1142
- 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);
1618
+ throw new errors_js_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);
1143
1619
  }
1144
1620
  }
1145
1621
  /**
@@ -1151,21 +1627,33 @@ class ChordRuntime {
1151
1627
  findRefusal(body, ctx) {
1152
1628
  for (const stmt of body) {
1153
1629
  if (stmt.kind === 'refuse')
1154
- return stmt.phraseKey;
1630
+ return this.resolvePhraseKey(stmt.phraseKey, ctx);
1155
1631
  if (stmt.kind === 'must') {
1156
1632
  if (!this.evaluator.evalCondition(stmt.condition, ctx))
1157
- return stmt.phraseKey;
1633
+ return this.resolvePhraseKey(stmt.phraseKey, ctx);
1158
1634
  continue;
1159
1635
  }
1160
1636
  if (stmt.kind === 'refuse-when') {
1161
1637
  if (this.evaluator.evalCondition(stmt.condition, ctx))
1162
- return stmt.phraseKey;
1638
+ return this.resolvePhraseKey(stmt.phraseKey, ctx);
1163
1639
  continue;
1164
1640
  }
1165
1641
  break; // first non-refusal statement ends the validate partition
1166
1642
  }
1167
1643
  return null;
1168
1644
  }
1645
+ /**
1646
+ * Resolve a refusal phrase key to its registered message id. A
1647
+ * per-entity `phrase <key>:` declaration registers entity-scoped as
1648
+ * `<irId>.<key>` — the same override rule `phraseEvent` applies at emit
1649
+ * time — so a bare refusal key written inside that entity's clause must
1650
+ * travel as the scoped id: the key crosses into stdlib's blocked() as a
1651
+ * fully-qualified message id (ADR-231 D1) and is rendered verbatim.
1652
+ */
1653
+ resolvePhraseKey(key, ctx) {
1654
+ const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
1655
+ return ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
1656
+ }
1169
1657
  /**
1170
1658
  * Snapshot every branching decision (`if` and `select-on`) before
1171
1659
  * mutations run, walking only the branches actually taken (§5.4: the
@@ -1232,7 +1720,7 @@ class ChordRuntime {
1232
1720
  // the persisted chance stream (via one draw per firing). Sticky (Z5)
1233
1721
  // reuses the same slot with the Choice encoding instead of an
1234
1722
  // occurrence count: stored = chosen index + 1, 0/undefined = unchosen.
1235
- const key = state_keys_1.CHORD_OCCURRENCE_PREFIX + `select.${stmt.span.line}`;
1723
+ const key = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + `select.${stmt.span.line}`;
1236
1724
  if (stmt.strategy === 'sticky') {
1237
1725
  const stored = ctx.world.getStateValue(key);
1238
1726
  if (stored && stored > 0)
@@ -1253,7 +1741,7 @@ class ChordRuntime {
1253
1741
  case 'randomly':
1254
1742
  return this.randomIndex(count, ctx);
1255
1743
  default:
1256
- throw new errors_1.LoadError(`Unknown select strategy \`${stmt.strategy}\`.`, stmt.span);
1744
+ throw new errors_js_1.LoadError(`Unknown select strategy \`${stmt.strategy}\`.`, stmt.span);
1257
1745
  }
1258
1746
  }
1259
1747
  randomIndex(count, ctx) {
@@ -1310,8 +1798,14 @@ class ChordRuntime {
1310
1798
  const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
1311
1799
  const overrideKey = ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
1312
1800
  const phrase = table[overrideKey];
1313
- if (!phrase)
1314
- throw new errors_1.LoadError(`Phrase \`${key}\` is missing from the IR at emit time.`);
1801
+ // ADR-250: a key covered only by phrasebooks has no table entry — emit
1802
+ // the bare key (the render-path book layer supplies the winning
1803
+ // template and its Choice) but still stage stmt params and any hatch
1804
+ // producers the book entries reference, since staging is emit-time work.
1805
+ const bookVariants = phrase ? null : this.bookEntriesFor(key).flatMap((e) => e.variants);
1806
+ if (!phrase && bookVariants.length === 0) {
1807
+ throw new errors_js_1.LoadError(`Phrase \`${key}\` is missing from the IR at emit time.`);
1808
+ }
1315
1809
  const params = {};
1316
1810
  // Authored `with <param> = <value>` bindings (zoo-chain follow-up,
1317
1811
  // 2026-07-12): entity values pass as their display name (the template's
@@ -1321,7 +1815,7 @@ class ChordRuntime {
1321
1815
  const asEntity = typeof value === 'string' ? ctx.world.getEntity(value) : undefined;
1322
1816
  params[p.param] = asEntity ? asEntity.name : value;
1323
1817
  }
1324
- for (const variant of phrase.variants) {
1818
+ for (const variant of phrase ? phrase.variants : bookVariants) {
1325
1819
  for (const marker of variant.markers) {
1326
1820
  const producer = this.host.producers.get(marker);
1327
1821
  if (producer) {
@@ -1332,10 +1826,10 @@ class ChordRuntime {
1332
1826
  // producer reaching outside it fails HERE, named, not as an
1333
1827
  // anonymous TypeError downstream.
1334
1828
  try {
1335
- params[marker] = producer((0, hatch_context_1.stagingRenderContext)(ctx.world));
1829
+ params[marker] = producer((0, hatch_context_js_1.stagingRenderContext)(ctx.world));
1336
1830
  }
1337
1831
  catch (error) {
1338
- 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).`);
1832
+ throw new errors_js_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).`);
1339
1833
  }
1340
1834
  }
1341
1835
  }
@@ -1353,16 +1847,16 @@ class ChordRuntime {
1353
1847
  params[name] = slotEntity.name;
1354
1848
  }
1355
1849
  }
1356
- if (phrase.verbatim) {
1850
+ if (phrase?.verbatim) {
1357
1851
  // `{verbatim:text}` template (loader registration) — the atom is
1358
1852
  // exempt from whitespace collapse, so line structure and interior
1359
1853
  // spacing survive as authored (grammar log 2026-07-10).
1360
1854
  params.text = phrase.variants[0]?.text ?? '';
1361
1855
  }
1362
- else if (phrase.strategy) {
1856
+ else if (phrase?.strategy) {
1363
1857
  const choice = {
1364
1858
  kind: 'choice',
1365
- alternatives: phrase.variants.map((v) => ({ kind: 'literal', text: (0, text_1.withLineBreaks)(v.text) })),
1859
+ alternatives: phrase.variants.map((v) => ({ kind: 'literal', text: (0, text_js_1.withLineBreaks)(v.text) })),
1366
1860
  selector: exports.STRATEGY_SELECTOR[phrase.strategy],
1367
1861
  entityId: counter?.entityId ?? 'chord',
1368
1862
  messageKey: counter?.messageKey ?? overrideKey,
@@ -1371,6 +1865,38 @@ class ChordRuntime {
1371
1865
  }
1372
1866
  return this.rawEvent('chord.phrase', { messageId: overrideKey, params });
1373
1867
  }
1868
+ /**
1869
+ * Evaluate an emit payload (ADR-216) against the live turn context.
1870
+ * Keys pass VERBATIM; number literals become numbers; `true`/`false`
1871
+ * symbols become booleans; other value expressions evaluate through the
1872
+ * shared evaluator (entity refs → world ids, field reads → live values).
1873
+ */
1874
+ emitPayload(fields, ctx) {
1875
+ const data = {};
1876
+ for (const field of fields ?? []) {
1877
+ data[field.key] = this.emitValue(field.value, ctx);
1878
+ }
1879
+ return data;
1880
+ }
1881
+ emitValue(value, ctx) {
1882
+ switch (value.kind) {
1883
+ case 'literal':
1884
+ return value.valueType === 'number' ? Number(value.value) : value.value;
1885
+ case 'value':
1886
+ if (value.value.kind === 'symbol' && (value.value.name === 'true' || value.value.name === 'false')) {
1887
+ return value.value.name === 'true';
1888
+ }
1889
+ return this.evaluator.evalValue(value.value, ctx);
1890
+ case 'array':
1891
+ return value.items.map((item) => this.emitValue(item, ctx));
1892
+ case 'object': {
1893
+ const nested = {};
1894
+ for (const field of value.fields)
1895
+ nested[field.key] = this.emitValue(field.value, ctx);
1896
+ return nested;
1897
+ }
1898
+ }
1899
+ }
1374
1900
  rawEvent(type, data) {
1375
1901
  return {
1376
1902
  id: `chord-${type}-${this.eventSeq++}`,
@@ -1386,13 +1912,13 @@ class ChordRuntime {
1386
1912
  return value.id;
1387
1913
  if (value.kind === 'it') {
1388
1914
  if (!ctx.it)
1389
- throw new errors_1.LoadError('`it` used outside an entity-scoped clause.');
1915
+ throw new errors_js_1.LoadError('`it` used outside an entity-scoped clause.');
1390
1916
  return ctx.it;
1391
1917
  }
1392
1918
  const worldId = this.evaluator.entityValue(value, ctx);
1393
1919
  const irId = this.host.irIdOf(worldId);
1394
1920
  if (!irId)
1395
- throw new errors_1.LoadError('Cannot change the state of a non-story entity.');
1921
+ throw new errors_js_1.LoadError('Cannot change the state of a non-story entity.');
1396
1922
  return irId;
1397
1923
  }
1398
1924
  }