@sharpee/story-loader 3.0.0 → 3.3.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,12 +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
- 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");
8
+ const stdlib_1 = require("@sharpee/stdlib");
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");
10
16
  /**
11
17
  * Chord strategy adverb → phrase-algebra Choice selector (ADR-196).
12
18
  * The Z5 table (ADR-211 Decision 4): adverbs mirror the selectors 1:1;
@@ -27,6 +33,11 @@ class ChordBehaviorTrait {
27
33
  type = ChordBehaviorTrait.type;
28
34
  }
29
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'];
30
41
  class ChordRuntime {
31
42
  ir;
32
43
  host;
@@ -57,80 +68,334 @@ class ChordRuntime {
57
68
  return;
58
69
  // Event clauses (`after entering it`) bind to the event stream per
59
70
  // 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);
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);
63
77
  return;
64
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
+ }
84
+ // D5 fail-fast (ADR-228): only bind clauses something will consult.
85
+ if (!this.isConsultedGerund(clause.action)) {
86
+ if (this.isDispatchAction(clause.action)) {
87
+ // Dispatch reactions fire via fireAfterClauses (the runtime owns
88
+ // those actions — interceptors never fire on the dispatch path),
89
+ // so `after` is live without any registration here…
90
+ if (clause.clauseKind === 'after')
91
+ return;
92
+ // …but an entity `on` clause has no dispatch surface at all.
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);
94
+ }
95
+ throw this.deadGerundError(clause);
96
+ }
65
97
  this.prepareOnClauseTarget(world, entity, clause);
66
98
  const list = byAction.get(clause.action) ?? [];
67
99
  list.push({ entity, clause });
68
100
  byAction.set(clause.action, list);
69
101
  });
70
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
+ }
71
119
  for (const [action, clauses] of byAction) {
72
- world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(clauses));
120
+ world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(action, clauses));
73
121
  }
74
122
  // Phase B: `define trait` clauses register per TRAIT TYPE — capability
75
123
  // behaviors for dispatch verbs, interceptors for standard-semantics
76
124
  // actions (§5.4 routing recorded on the IR by the analyzer).
77
125
  for (const trait of this.ir.traits) {
78
- 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();
79
129
  for (const clause of trait.onClauses) {
80
130
  if (clause.binding === 'every-turn')
81
131
  continue; // scheduler phase (plan phase 5)
82
132
  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);
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);
84
134
  }
85
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);
86
144
  world.registerCapabilityBehavior(traitType, `chord.action.${clause.action}`, this.buildCapabilityBehavior(trait.name, clause));
87
145
  }
88
146
  else {
89
- world.registerActionInterceptor(traitType, `if.action.${clause.action}`, this.buildTraitInterceptor(clause));
147
+ // D5 fail-fast (ADR-228): the analyzer routed this clause to the
148
+ // interceptor path, so its gerund must name a consulted action.
149
+ if (!this.isConsultedGerund(clause.action))
150
+ throw this.deadGerundError(clause);
151
+ const list = interceptorClauses.get(clause.action) ?? [];
152
+ list.push(clause);
153
+ interceptorClauses.set(clause.action, list);
90
154
  }
91
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
+ }
92
162
  }
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 });
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));
113
342
  }
114
343
  }
115
344
  }
345
+ // ------------------------------------------------------- D5 fail-fast
346
+ /**
347
+ * True when an interceptor registered under `if.action.<gerund>` can ever
348
+ * fire: a wired stdlib action consults the id (the ADR-228 D5 registry,
349
+ * derived from the descriptor table), or the gerund names a `define
350
+ * action X from` hatch — an author-owned TS Action the loader can't see
351
+ * inside, which may consult its own id.
352
+ * @param gerund the clause's action word (e.g. `taking`)
353
+ */
354
+ isConsultedGerund(gerund) {
355
+ if (stdlib_1.interceptorConsultingActionIds.has(`if.action.${gerund}`))
356
+ return true;
357
+ return this.ir.hatches.some((h) => h.hatchKind === 'action' && h.name === gerund);
358
+ }
359
+ /** True when the gerund names a `define action` dispatch action. */
360
+ isDispatchAction(gerund) {
361
+ return this.ir.actions.some((a) => a.name === gerund);
362
+ }
363
+ /**
364
+ * Load-time diagnostic for a clause whose gerund nothing will ever
365
+ * consult (ADR-228 D5): a typo or an unimplemented action word would
366
+ * otherwise register and silently die. lowering/raising get the pointed
367
+ * capability-dispatch message (they are full-delegation by design).
368
+ * @param clause the dead clause (its span anchors the diagnostic)
369
+ */
370
+ deadGerundError(clause) {
371
+ const phrase = `${clause.clauseKind} ${clause.action} it`;
372
+ if (clause.action === 'lowering' || clause.action === 'raising') {
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);
374
+ }
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);
376
+ }
116
377
  // ---------------------------------------------------------- event clauses
117
378
  /**
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.
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.
122
383
  */
123
- bindEventClause(world, entity, clause, clauseIndex) {
124
- const trigger = event_contract_1.EVENT_TRIGGERS[clause.action];
384
+ bindEventClause(world, entity, clause, clauseIndex, trigger) {
125
385
  const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
126
386
  world.chainEvent(trigger, (event, w) => this.fireEventClause(entity, clause, key, event, w), { key });
127
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
+ }
128
393
  /** Test/debug entry: run every event clause bound to this event type. */
129
394
  fireEventClauses(world, event) {
130
395
  const out = [];
131
396
  for (const entity of this.ir.entities) {
132
397
  entity.onClauses.forEach((clause, clauseIndex) => {
133
- 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)
134
399
  return;
135
400
  const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
136
401
  const produced = this.fireEventClause(entity, clause, key, event, world);
@@ -141,15 +406,24 @@ class ChordRuntime {
141
406
  return out;
142
407
  }
143
408
  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 ownerread 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))
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).
148
421
  return null;
422
+ }
149
423
  const ctx = { world, it: entity.id };
150
424
  if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
151
425
  return null;
152
- const occKey = state_keys_1.CHORD_OCCURRENCE_PREFIX + key;
426
+ const occKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + key;
153
427
  const occurrence = (world.getStateValue(occKey) ?? 0) + 1;
154
428
  if (clause.once && occurrence > 1)
155
429
  return null; // `, once` — one lifetime firing (D5)
@@ -163,10 +437,10 @@ class ChordRuntime {
163
437
  prepareOnClauseTarget(world, entity, clause) {
164
438
  const worldId = this.host.entityId(entity.id);
165
439
  if (!worldId)
166
- 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);
167
441
  const target = world.getEntity(worldId);
168
442
  if (!target)
169
- 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);
170
444
  if (!target.has(ChordBehaviorTrait.type)) {
171
445
  target.add(new ChordBehaviorTrait());
172
446
  }
@@ -175,17 +449,106 @@ class ChordRuntime {
175
449
  target.add(new world_model_1.ReadableTrait({ text: '' }));
176
450
  }
177
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
+ }
464
+ /**
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.
469
+ */
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
+ }
178
525
  /**
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).
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).
182
533
  */
183
- buildDispatchingInterceptor(clauses) {
534
+ buildDispatchingInterceptor(action, clauses) {
184
535
  const runtime = this;
185
- const arms = clauses.map(({ entity, clause }) => ({
186
- entity,
187
- interceptor: this.buildInterceptor(entity, clause),
188
- }));
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
+ });
189
552
  const armFor = (target) => arms.find((a) => runtime.host.entityId(a.entity.id) === target.id)?.interceptor;
190
553
  return {
191
554
  preValidate(target, world, actorId, data) {
@@ -209,48 +572,67 @@ class ChordRuntime {
209
572
  * win/lose → postReport. An `on` clause's first phrase OVERRIDES the
210
573
  * primary message; an `after` clause's phrases APPEND (D3).
211
574
  */
212
- buildInterceptor(entity, clause) {
575
+ buildInterceptor(entity, clause, ns) {
213
576
  const runtime = this;
214
577
  const ownWorldId = () => runtime.host.entityId(entity.id);
215
- const skipped = (data) => data.chordSkip === true;
578
+ const occurrenceKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + `on.${ns}`;
216
579
  return {
217
- preValidate(target, world) {
580
+ preValidate(target, world, _actorId, data) {
218
581
  if (target.id !== ownWorldId() || clause.clauseKind === 'after')
219
582
  return null;
583
+ const bag = runtime.clauseBag(data, ns);
220
584
  const ctx = { world, it: entity.id };
585
+ // D8 (ADR-228): the `while` gate is evaluated once per firing, at
586
+ // validate time, BEFORE findRefusal — a gated-out clause sits out
587
+ // entirely, refusals included. preValidate and postValidate may both
588
+ // evaluate the gate: no mutation occurs between them within one
589
+ // action, so the answers cannot differ. Do not move this evaluation.
590
+ if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
591
+ bag.skip = true;
592
+ return null;
593
+ }
594
+ // `, once`: a clause that has already fired keeps its refusal out
595
+ // too (peek only — the occurrence bump stays in postValidate).
596
+ if (clause.once && (world.getStateValue(occurrenceKey) ?? 0) >= 1) {
597
+ bag.skip = true;
598
+ return null;
599
+ }
221
600
  const refusal = runtime.findRefusal(clause.body, ctx);
222
601
  return refusal ? { valid: false, error: refusal } : null;
223
602
  },
224
603
  postValidate(target, world, _actorId, data) {
225
604
  if (target.id !== ownWorldId())
226
605
  return null;
606
+ const bag = runtime.clauseBag(data, ns);
227
607
  const ctx = { world, it: entity.id };
608
+ // D8: same gate, same evaluation point (see preValidate).
228
609
  if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
229
- data.chordSkip = true; // `while <cond>` gate — clause sits out this firing
610
+ bag.skip = true; // `while <cond>` gate — clause sits out this firing
230
611
  return null;
231
612
  }
232
- const key = state_keys_1.CHORD_OCCURRENCE_PREFIX + `on.${entity.id}.${clause.action}`;
233
- const occurrence = (world.getStateValue(key) ?? 0) + 1;
613
+ const occurrence = (world.getStateValue(occurrenceKey) ?? 0) + 1;
234
614
  if (clause.once && occurrence > 1) {
235
- data.chordSkip = true; // `, once` — one lifetime firing (D5)
615
+ bag.skip = true; // `, once` — one lifetime firing (D5)
236
616
  return null;
237
617
  }
238
- world.setStateValue(key, occurrence);
618
+ world.setStateValue(occurrenceKey, occurrence);
239
619
  ctx.occurrence = occurrence;
240
- data.chordOccurrence = occurrence;
241
- data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
620
+ bag.occurrence = occurrence;
621
+ bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
242
622
  return null;
243
623
  },
244
624
  postExecute(target, world, _actorId, data) {
245
- if (target.id !== ownWorldId() || skipped(data))
625
+ const bag = runtime.clauseBag(data, ns);
626
+ if (target.id !== ownWorldId() || bag.skip === true)
246
627
  return;
247
- const ctx = runtime.restoreCtx(world, entity.id, data);
628
+ const ctx = runtime.restoreCtx(world, entity.id, bag);
248
629
  runtime.execStatements(clause.body, ctx, 'mutations');
249
630
  },
250
631
  postReport(target, world, _actorId, data) {
251
- if (target.id !== ownWorldId() || skipped(data))
632
+ const bag = runtime.clauseBag(data, ns);
633
+ if (target.id !== ownWorldId() || bag.skip === true)
252
634
  return {};
253
- const ctx = runtime.restoreCtx(world, entity.id, data);
635
+ const ctx = runtime.restoreCtx(world, entity.id, bag);
254
636
  const reports = runtime.execStatements(clause.body, ctx, 'reports');
255
637
  const result = {};
256
638
  const emit = [];
@@ -272,12 +654,110 @@ class ChordRuntime {
272
654
  },
273
655
  };
274
656
  }
275
- restoreCtx(world, itIrId, data) {
657
+ restoreCtx(world, itIrId, bag) {
276
658
  return {
277
659
  world,
278
660
  it: itIrId,
279
- occurrence: data.chordOccurrence,
280
- 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
+ },
281
761
  };
282
762
  }
283
763
  // ------------------------------------------- dispatch verbs (Phase B, §5.4)
@@ -285,7 +765,7 @@ class ChordRuntime {
285
765
  writeChordTraitField(world, worldId, field, value, span) {
286
766
  const entity = world.getEntity(worldId);
287
767
  for (const trait of entity?.traits.values() ?? []) {
288
- if (!trait.type.startsWith(state_keys_1.CHORD_TRAIT_PREFIX))
768
+ if (!trait.type.startsWith(state_keys_js_1.CHORD_TRAIT_PREFIX))
289
769
  continue;
290
770
  const record = trait;
291
771
  if (field in record) {
@@ -293,7 +773,7 @@ class ChordRuntime {
293
773
  return;
294
774
  }
295
775
  }
296
- 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);
297
777
  }
298
778
  /**
299
779
  * A `define trait` clause on a dispatch verb → CapabilityBehavior
@@ -313,11 +793,25 @@ class ChordRuntime {
313
793
  return {
314
794
  validate(entity, world, actorId, data) {
315
795
  const ctx = ctxOf(entity, world, actorId, data);
796
+ // D8 (ADR-228): the `while` gate is evaluated once per firing, at
797
+ // validate time, BEFORE findRefusal — a gated-out clause sits out
798
+ // entirely, refusals included. ADR-229 R5: the dispatch action
799
+ // reads `chordSkip` as "not claiming" and falls through to the
800
+ // next candidate / body / miss; the execute/report guards below
801
+ // stay as defense in depth. Do not move this evaluation.
802
+ if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
803
+ data.chordSkip = true;
804
+ return { valid: true };
805
+ }
806
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${traitName}.${clause.action}.${runtime.host.irIdOf(entity.id)}`;
807
+ const occurrence = (world.getStateValue(key) ?? 0) + 1;
808
+ if (clause.once && occurrence > 1) {
809
+ data.chordSkip = true; // `, once` — one lifetime firing (D5)
810
+ return { valid: true };
811
+ }
316
812
  const refusal = runtime.findRefusal(clause.body, ctx);
317
813
  if (refusal)
318
814
  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
815
  world.setStateValue(key, occurrence);
322
816
  ctx.occurrence = occurrence;
323
817
  data.chordOccurrence = occurrence;
@@ -325,9 +819,13 @@ class ChordRuntime {
325
819
  return { valid: true };
326
820
  },
327
821
  execute(entity, world, actorId, data) {
822
+ if (data.chordSkip === true)
823
+ return;
328
824
  runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'mutations');
329
825
  },
330
826
  report(entity, world, actorId, data) {
827
+ if (data.chordSkip === true)
828
+ return [];
331
829
  const events = runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'reports');
332
830
  return events.map((e) => ({ type: e.type, payload: (e.data ?? {}) }));
333
831
  },
@@ -342,33 +840,72 @@ class ChordRuntime {
342
840
  * ActionInterceptor registered under the trait type (ADR-118 resolves it
343
841
  * for every entity carrying the trait).
344
842
  */
345
- buildTraitInterceptor(clause) {
843
+ buildTraitInterceptor(clause, ns) {
346
844
  const runtime = this;
347
845
  const itOf = (target) => runtime.host.irIdOf(target.id) ?? target.id;
846
+ const occurrenceKeyOf = (target) => `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${ns}.${itOf(target)}`;
348
847
  return {
349
- preValidate(target, world) {
350
- const refusal = runtime.findRefusal(clause.body, { world, it: itOf(target) });
848
+ preValidate(target, world, _actorId, data) {
849
+ if (clause.clauseKind === 'after')
850
+ return null;
851
+ const ctx = { world, it: itOf(target) };
852
+ // D8 (ADR-228): the `while` gate is evaluated once per firing, at
853
+ // validate time, BEFORE findRefusal — a gated-out clause sits out
854
+ // entirely, refusals included. preValidate and postValidate may both
855
+ // evaluate the gate: no mutation occurs between them within one
856
+ // action, so the answers cannot differ. Do not move this evaluation.
857
+ const bag = runtime.clauseBag(data, ns);
858
+ if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
859
+ bag.skip = true;
860
+ return null;
861
+ }
862
+ // `, once`: a clause that has already fired keeps its refusal out
863
+ // too (peek only — the occurrence bump stays in postValidate).
864
+ if (clause.once && (world.getStateValue(occurrenceKeyOf(target)) ?? 0) >= 1) {
865
+ bag.skip = true;
866
+ return null;
867
+ }
868
+ const refusal = runtime.findRefusal(clause.body, ctx);
351
869
  return refusal ? { valid: false, error: refusal } : null;
352
870
  },
353
871
  postValidate(target, world, _actorId, data) {
354
- const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${clause.action}.${itOf(target)}`;
872
+ const bag = runtime.clauseBag(data, ns);
873
+ const ctx = { world, it: itOf(target) };
874
+ // D8: same gate, same evaluation point (see preValidate).
875
+ if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
876
+ bag.skip = true; // `while <cond>` gate — clause sits out this firing
877
+ return null;
878
+ }
879
+ const key = occurrenceKeyOf(target);
355
880
  const occurrence = (world.getStateValue(key) ?? 0) + 1;
881
+ if (clause.once && occurrence > 1) {
882
+ bag.skip = true; // `, once` — one lifetime firing (D5)
883
+ return null;
884
+ }
356
885
  world.setStateValue(key, occurrence);
357
- const ctx = { world, it: itOf(target), occurrence };
358
- data.chordOccurrence = occurrence;
359
- data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
886
+ ctx.occurrence = occurrence;
887
+ bag.occurrence = occurrence;
888
+ bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
360
889
  return null;
361
890
  },
362
891
  postExecute(target, world, _actorId, data) {
363
- runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'mutations');
892
+ const bag = runtime.clauseBag(data, ns);
893
+ if (bag.skip === true)
894
+ return;
895
+ runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'mutations');
364
896
  },
365
897
  postReport(target, world, _actorId, data) {
366
- const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'reports');
898
+ const bag = runtime.clauseBag(data, ns);
899
+ if (bag.skip === true)
900
+ return {};
901
+ const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'reports');
367
902
  const result = {};
368
903
  const emit = [];
369
904
  for (const event of reports) {
370
905
  const payload = (event.data ?? {});
371
- 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) {
372
909
  result.override = { messageId: String(payload.messageId), params: payload.params ?? {} };
373
910
  }
374
911
  else {
@@ -431,24 +968,36 @@ class ChordRuntime {
431
968
  // Instance-type lookup: ChordDataTrait types are per-instance, so
432
969
  // the constructor-static path (getBehaviorForCapability) can't see
433
970
  // them.
971
+ //
972
+ // ADR-229 R5: a gated-out behavior does NOT claim the dispatch.
973
+ // A candidate whose validate returns valid with `chordSkip` set
974
+ // (false `while` gate, or consumed `, once` — both side-effect-free
975
+ // probes) is treated as if its clause were never declared: selection
976
+ // falls through to the next trait's behavior, the action body, or
977
+ // the `otherwise refuse` miss. A real refusal (valid: false) still
978
+ // claims immediately, exactly as before.
434
979
  let behavior;
980
+ let capShared = { chordSlots: slots };
435
981
  for (const trait of entity.traits.values()) {
436
- behavior = context.world.getBehaviorBinding(trait.type, actionId)?.behavior;
437
- if (behavior)
438
- break;
982
+ const candidate = context.world.getBehaviorBinding(trait.type, actionId)?.behavior;
983
+ if (!candidate)
984
+ continue;
985
+ const candidateShared = { chordSlots: slots };
986
+ const result = candidate.validate(entity, context.world, context.player.id, candidateShared);
987
+ if (!result.valid)
988
+ return { valid: false, error: result.error };
989
+ if (candidateShared.chordSkip === true)
990
+ continue; // gated out — not claiming
991
+ behavior = candidate;
992
+ capShared = candidateShared;
993
+ break;
439
994
  }
440
995
  // A behavior host is optional when the action carries its own body
441
996
  // (§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.
997
+ // per-trait behavior by design). No claiming behavior AND no body =
998
+ // the dispatch miss.
444
999
  if (!behavior && def.body.length === 0)
445
1000
  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
1001
  if (def.body.length) {
453
1002
  // The body's own validate partition (leading refusals/musts) and
454
1003
  // the pre-mutation decision snapshot (§5.4).
@@ -491,6 +1040,14 @@ class ChordRuntime {
491
1040
  },
492
1041
  blocked(context, result) {
493
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
+ }
494
1051
  const event = runtime.phraseEvent(key, { world: context.world });
495
1052
  return [context.event(event.type, (event.data ?? {}))];
496
1053
  },
@@ -530,7 +1087,7 @@ class ChordRuntime {
530
1087
  // <state>` on a state anchor (ratchet D10). Pointer and last-fired
531
1088
  // turn live in world state — save/restore covers progression.
532
1089
  const slug = sequence.name.replace(/\s+/g, '-');
533
- const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
1090
+ const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
534
1091
  const firedKey = `${key}.turn`;
535
1092
  const stepReady = (step, world, turn) => {
536
1093
  switch (step.timing) {
@@ -544,9 +1101,9 @@ class ChordRuntime {
544
1101
  if (!step.anchor)
545
1102
  return false;
546
1103
  if (step.anchor.owner === 'story') {
547
- 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;
548
1105
  }
549
- 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;
550
1107
  }
551
1108
  }
552
1109
  };
@@ -574,7 +1131,7 @@ class ChordRuntime {
574
1131
  irEntity.onClauses.forEach((clause, clauseIndex) => {
575
1132
  if (clause.binding !== 'every-turn')
576
1133
  return;
577
- 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}`;
578
1135
  daemons.push({
579
1136
  id: `chord.entity-turn.${irEntity.id}.${clauseIndex}`,
580
1137
  name: `on every turn (${irEntity.id})`,
@@ -599,6 +1156,31 @@ class ChordRuntime {
599
1156
  });
600
1157
  });
601
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
+ });
602
1184
  // Every-turn trait clauses (`on every turn while …[, once]`): one
603
1185
  // daemon per clause, evaluated per entity carrying the trait. The
604
1186
  // composition condition (`chatty while not after-hours`) gates per
@@ -607,7 +1189,7 @@ class ChordRuntime {
607
1189
  trait.onClauses.forEach((clause, clauseIndex) => {
608
1190
  if (clause.binding !== 'every-turn')
609
1191
  return;
610
- const traitType = state_keys_1.CHORD_TRAIT_PREFIX + trait.name;
1192
+ const traitType = state_keys_js_1.CHORD_TRAIT_PREFIX + trait.name;
611
1193
  daemons.push({
612
1194
  id: `chord.trait-turn.${trait.name}.${clauseIndex}`,
613
1195
  name: `on every turn (${trait.name})`,
@@ -630,7 +1212,7 @@ class ChordRuntime {
630
1212
  continue;
631
1213
  if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
632
1214
  continue;
633
- 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}`;
634
1216
  const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
635
1217
  if (clause.once && fired > 1)
636
1218
  continue; // `, once` (D5)
@@ -663,11 +1245,23 @@ class ChordRuntime {
663
1245
  narrated(events) {
664
1246
  return events.map((e) => ({ ...e, narrate: true }));
665
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
+ }
666
1258
  /**
667
1259
  * 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).
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).
671
1265
  */
672
1266
  playerPresentAt(world, irEntityId) {
673
1267
  const ownerId = this.host.entityId(irEntityId);
@@ -676,8 +1270,10 @@ class ChordRuntime {
676
1270
  return false;
677
1271
  if (ownerId === playerId)
678
1272
  return true;
679
- const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
680
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);
681
1277
  if (owner?.has(world_model_1.TraitType.ROOM))
682
1278
  return playerRoom === ownerId;
683
1279
  const ownerRoom = world.getContainingRoom(ownerId)?.id ?? world.getLocation(ownerId);
@@ -703,7 +1299,7 @@ class ChordRuntime {
703
1299
  const ctx = { world, it: targetIrId };
704
1300
  if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
705
1301
  return;
706
- 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}`;
707
1303
  const occurrence = (world.getStateValue(key) ?? 0) + 1;
708
1304
  if (clause.once && occurrence > 1)
709
1305
  return; // `, once` (D5)
@@ -725,49 +1321,53 @@ class ChordRuntime {
725
1321
  }
726
1322
  return out;
727
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.
728
1327
  /**
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).
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.
732
1333
  */
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
- }
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;
759
1368
  }
760
1369
  }
761
1370
  }
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
1371
  // ------------------------------------------------------------ statements
772
1372
  /**
773
1373
  * Execute a statement tree. `phase` narrows which leaves act:
@@ -788,8 +1388,15 @@ class ChordRuntime {
788
1388
  events.push(this.phraseEvent(stmt.phraseKey, ctx, stmt.params));
789
1389
  break;
790
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.
791
1398
  if (phase !== 'mutations' && whenHolds(stmt))
792
- 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)));
793
1400
  break;
794
1401
  case 'win':
795
1402
  case 'lose':
@@ -799,20 +1406,40 @@ class ChordRuntime {
799
1406
  events.push(this.host.triggerEnding(ctx.world, stmt.kind === 'win' ? 'victory' : 'defeat', stmt.phraseKey ?? undefined));
800
1407
  }
801
1408
  break;
1409
+ case 'kill':
1410
+ // `kill the player` (ADR-227 Decision 4): terminal death via the
1411
+ // platform's killPlayer sink — the engine routes game-over off the
1412
+ // canonical if.event.player.died it returns; triggerEnding is NOT
1413
+ // called (a distinct lowering target from win/lose). The phrase
1414
+ // carries the death text; the cause derives from the phrase key.
1415
+ if (phase !== 'mutations' && whenHolds(stmt)) {
1416
+ if (stmt.phraseKey)
1417
+ events.push(this.phraseEvent(stmt.phraseKey, ctx));
1418
+ const player = ctx.world.getPlayer();
1419
+ if (player) {
1420
+ const died = (0, stdlib_1.killPlayer)(ctx.world, player, {
1421
+ cause: stmt.phraseKey ?? 'killed',
1422
+ terminal: true,
1423
+ });
1424
+ if (died)
1425
+ events.push(died);
1426
+ }
1427
+ }
1428
+ break;
802
1429
  case 'change': {
803
1430
  if (phase !== 'reports' && whenHolds(stmt)) {
804
1431
  if (stmt.entity.kind === 'story') {
805
1432
  // `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);
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);
808
1435
  }
809
1436
  else {
810
1437
  const irId = this.irIdOfValue(stmt.entity, ctx);
811
1438
  const set = this.stateSetOf(irId, stmt.state);
812
1439
  if (set) {
813
- 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);
814
1441
  }
815
- 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);
816
1443
  }
817
1444
  }
818
1445
  break;
@@ -846,7 +1473,7 @@ class ChordRuntime {
846
1473
  this.writeChordTraitField(ctx.world, baseId, stmt.target.field, value, stmt.span);
847
1474
  }
848
1475
  else {
849
- 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);
850
1477
  }
851
1478
  }
852
1479
  break;
@@ -857,12 +1484,12 @@ class ChordRuntime {
857
1484
  // awards are no-ops and `, once` is automatic. Names arrive
858
1485
  // owner-qualified from the analyzer (ratchet D12).
859
1486
  if (stmt.expression.length !== 1) {
860
- 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);
861
1488
  }
862
1489
  const name = stmt.expression[0];
863
1490
  const worth = this.scoreWorth.get(name);
864
1491
  if (worth === undefined) {
865
- 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);
866
1493
  }
867
1494
  ctx.world.awardScore(name, worth, name);
868
1495
  }
@@ -988,7 +1615,7 @@ class ChordRuntime {
988
1615
  const from = states.indexOf(current);
989
1616
  const to = states.indexOf(target);
990
1617
  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);
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);
992
1619
  }
993
1620
  }
994
1621
  /**
@@ -1000,21 +1627,33 @@ class ChordRuntime {
1000
1627
  findRefusal(body, ctx) {
1001
1628
  for (const stmt of body) {
1002
1629
  if (stmt.kind === 'refuse')
1003
- return stmt.phraseKey;
1630
+ return this.resolvePhraseKey(stmt.phraseKey, ctx);
1004
1631
  if (stmt.kind === 'must') {
1005
1632
  if (!this.evaluator.evalCondition(stmt.condition, ctx))
1006
- return stmt.phraseKey;
1633
+ return this.resolvePhraseKey(stmt.phraseKey, ctx);
1007
1634
  continue;
1008
1635
  }
1009
1636
  if (stmt.kind === 'refuse-when') {
1010
1637
  if (this.evaluator.evalCondition(stmt.condition, ctx))
1011
- return stmt.phraseKey;
1638
+ return this.resolvePhraseKey(stmt.phraseKey, ctx);
1012
1639
  continue;
1013
1640
  }
1014
1641
  break; // first non-refusal statement ends the validate partition
1015
1642
  }
1016
1643
  return null;
1017
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
+ }
1018
1657
  /**
1019
1658
  * Snapshot every branching decision (`if` and `select-on`) before
1020
1659
  * mutations run, walking only the branches actually taken (§5.4: the
@@ -1081,7 +1720,7 @@ class ChordRuntime {
1081
1720
  // the persisted chance stream (via one draw per firing). Sticky (Z5)
1082
1721
  // reuses the same slot with the Choice encoding instead of an
1083
1722
  // occurrence count: stored = chosen index + 1, 0/undefined = unchosen.
1084
- 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}`;
1085
1724
  if (stmt.strategy === 'sticky') {
1086
1725
  const stored = ctx.world.getStateValue(key);
1087
1726
  if (stored && stored > 0)
@@ -1102,7 +1741,7 @@ class ChordRuntime {
1102
1741
  case 'randomly':
1103
1742
  return this.randomIndex(count, ctx);
1104
1743
  default:
1105
- 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);
1106
1745
  }
1107
1746
  }
1108
1747
  randomIndex(count, ctx) {
@@ -1159,8 +1798,14 @@ class ChordRuntime {
1159
1798
  const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
1160
1799
  const overrideKey = ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
1161
1800
  const phrase = table[overrideKey];
1162
- if (!phrase)
1163
- 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
+ }
1164
1809
  const params = {};
1165
1810
  // Authored `with <param> = <value>` bindings (zoo-chain follow-up,
1166
1811
  // 2026-07-12): entity values pass as their display name (the template's
@@ -1170,7 +1815,7 @@ class ChordRuntime {
1170
1815
  const asEntity = typeof value === 'string' ? ctx.world.getEntity(value) : undefined;
1171
1816
  params[p.param] = asEntity ? asEntity.name : value;
1172
1817
  }
1173
- for (const variant of phrase.variants) {
1818
+ for (const variant of phrase ? phrase.variants : bookVariants) {
1174
1819
  for (const marker of variant.markers) {
1175
1820
  const producer = this.host.producers.get(marker);
1176
1821
  if (producer) {
@@ -1181,10 +1826,10 @@ class ChordRuntime {
1181
1826
  // producer reaching outside it fails HERE, named, not as an
1182
1827
  // anonymous TypeError downstream.
1183
1828
  try {
1184
- params[marker] = producer((0, hatch_context_1.stagingRenderContext)(ctx.world));
1829
+ params[marker] = producer((0, hatch_context_js_1.stagingRenderContext)(ctx.world));
1185
1830
  }
1186
1831
  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).`);
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).`);
1188
1833
  }
1189
1834
  }
1190
1835
  }
@@ -1202,16 +1847,16 @@ class ChordRuntime {
1202
1847
  params[name] = slotEntity.name;
1203
1848
  }
1204
1849
  }
1205
- if (phrase.verbatim) {
1850
+ if (phrase?.verbatim) {
1206
1851
  // `{verbatim:text}` template (loader registration) — the atom is
1207
1852
  // exempt from whitespace collapse, so line structure and interior
1208
1853
  // spacing survive as authored (grammar log 2026-07-10).
1209
1854
  params.text = phrase.variants[0]?.text ?? '';
1210
1855
  }
1211
- else if (phrase.strategy) {
1856
+ else if (phrase?.strategy) {
1212
1857
  const choice = {
1213
1858
  kind: 'choice',
1214
- 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) })),
1215
1860
  selector: exports.STRATEGY_SELECTOR[phrase.strategy],
1216
1861
  entityId: counter?.entityId ?? 'chord',
1217
1862
  messageKey: counter?.messageKey ?? overrideKey,
@@ -1220,6 +1865,38 @@ class ChordRuntime {
1220
1865
  }
1221
1866
  return this.rawEvent('chord.phrase', { messageId: overrideKey, params });
1222
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
+ }
1223
1900
  rawEvent(type, data) {
1224
1901
  return {
1225
1902
  id: `chord-${type}-${this.eventSeq++}`,
@@ -1235,13 +1912,13 @@ class ChordRuntime {
1235
1912
  return value.id;
1236
1913
  if (value.kind === 'it') {
1237
1914
  if (!ctx.it)
1238
- 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.');
1239
1916
  return ctx.it;
1240
1917
  }
1241
1918
  const worldId = this.evaluator.entityValue(value, ctx);
1242
1919
  const irId = this.host.irIdOf(worldId);
1243
1920
  if (!irId)
1244
- 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.');
1245
1922
  return irId;
1246
1923
  }
1247
1924
  }