@sharpee/story-loader 3.1.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/loader.js CHANGED
@@ -27,15 +27,20 @@ exports.createStory = createStory;
27
27
  const chord_1 = require("@sharpee/chord");
28
28
  const stdlib_1 = require("@sharpee/stdlib");
29
29
  const if_domain_1 = require("@sharpee/if-domain");
30
- const helpers_1 = require("@sharpee/helpers");
30
+ const plugin_npc_1 = require("@sharpee/plugin-npc");
31
31
  const plugin_scheduler_1 = require("@sharpee/plugin-scheduler");
32
+ const plugin_state_machine_1 = require("@sharpee/plugin-state-machine");
33
+ const stdlib_2 = require("@sharpee/stdlib");
32
34
  const world_model_1 = require("@sharpee/world-model");
33
- const errors_1 = require("./errors");
34
- const evaluator_1 = require("./evaluator");
35
- const hatch_context_1 = require("./hatch-context");
36
- const runtime_1 = require("./runtime");
37
- const state_keys_1 = require("./state-keys");
38
- const text_1 = require("./text");
35
+ const chain_map_js_1 = require("./chain-map.js");
36
+ const errors_js_1 = require("./errors.js");
37
+ const event_id_map_js_1 = require("./event-id-map.js");
38
+ const extension_registry_js_1 = require("./extension-registry.js");
39
+ const evaluator_js_1 = require("./evaluator.js");
40
+ const hatch_context_js_1 = require("./hatch-context.js");
41
+ const runtime_js_1 = require("./runtime.js");
42
+ const state_keys_js_1 = require("./state-keys.js");
43
+ const text_js_1 = require("./text.js");
39
44
  /**
40
45
  * Marker trait for entities carrying loader-compiled `detail` providers
41
46
  * (Z3b): the one state-clause contributor registered per load looks up the
@@ -76,8 +81,8 @@ class ChordStory {
76
81
  producers = new Map();
77
82
  /** Bound `define action X from` hatches: four-phase Action objects by name. */
78
83
  boundActions = new Map();
79
- /** Bound `define behavior X from` hatches: CapabilityBehaviors by name. */
80
- boundBehaviors = new Map();
84
+ /** Bound `define chain X from` hatches (ADR-094): EventChainHandlers by chain alias. */
85
+ boundChains = new Map();
81
86
  /** The turn-by-turn runtime (rules, on-clauses, derived properties). */
82
87
  runtime;
83
88
  /** The condition evaluator — shared with the runtime; Z2 gate thunks close over it. */
@@ -98,7 +103,7 @@ class ChordStory {
98
103
  const lower = name.toLowerCase();
99
104
  const target = this.ir.entities.find((e) => e.name.toLowerCase() === lower || e.aka.includes(lower));
100
105
  if (!target) {
101
- throw new errors_1.LoadError(`\`${name}\` (config \`${configKey}\`) names no entity.`, span);
106
+ throw new errors_js_1.LoadError(`\`${name}\` (config \`${configKey}\`) names no entity.`, span);
102
107
  }
103
108
  return { irRefId: target.id, ownerName: owner.name, span, apply };
104
109
  }
@@ -120,7 +125,7 @@ class ChordStory {
120
125
  constructor(ir, options) {
121
126
  this.ir = ir;
122
127
  if (ir.format !== chord_1.IR_FORMAT) {
123
- throw new errors_1.LoadError(`Unsupported IR format \`${String(ir.format)}\` — this loader reads \`${chord_1.IR_FORMAT}\`.`);
128
+ throw new errors_js_1.LoadError(`Unsupported IR format \`${String(ir.format)}\` — this loader reads \`${chord_1.IR_FORMAT}\`.`);
124
129
  }
125
130
  this.config = {
126
131
  id: ir.meta.fields.id ?? ir.meta.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
@@ -130,8 +135,8 @@ class ChordStory {
130
135
  description: ir.meta.fields.blurb,
131
136
  };
132
137
  this.bindHatches(options);
133
- this.evaluator = new evaluator_1.Evaluator(ir, this, options.seed);
134
- this.runtime = new runtime_1.ChordRuntime(ir, this, this.evaluator);
138
+ this.evaluator = new evaluator_js_1.Evaluator(ir, this, options.seed);
139
+ this.runtime = new runtime_js_1.ChordRuntime(ir, this, this.evaluator);
135
140
  }
136
141
  /** The world entity ID for an IR entity ID (after initializeWorld). */
137
142
  entityId(irId) {
@@ -151,29 +156,31 @@ class ChordStory {
151
156
  // any module — no author-supplied code is read, called, or bound.
152
157
  if ((options.profile ?? 'devkit') === 'pure-ir' && this.ir.hatches.length > 0) {
153
158
  const names = this.ir.hatches.map((h) => `\`${h.name}\` (${h.modulePath})`).join(', ');
154
- throw new errors_1.LoadError(`This profile runs pure-IR stories only — the story declares ${this.ir.hatches.length} TS hatch(es): ${names}. Load it with the devkit profile, or remove the hatches.`);
159
+ throw new errors_js_1.LoadError(`This profile runs pure-IR stories only — the story declares ${this.ir.hatches.length} TS hatch(es): ${names}. Load it with the devkit profile, or remove the hatches.`);
155
160
  }
156
161
  for (const hatch of this.ir.hatches) {
157
162
  const module = options.hatchModules?.[hatch.modulePath];
158
163
  if (!module) {
159
- throw new errors_1.LoadError(`Hatch module \`${hatch.modulePath}\` was not provided to the loader.`, hatch.span);
164
+ throw new errors_js_1.LoadError(`Hatch module \`${hatch.modulePath}\` was not provided to the loader.`, hatch.span);
160
165
  }
161
- const bound = module[hatch.name];
166
+ // A chain hatch's alias (`opened-revealed`) is not a JS identifier, so its
167
+ // module default-exports the handler (falling back to a matching named export).
168
+ const bound = hatch.hatchKind === 'chain' ? (module[hatch.name] ?? module.default) : module[hatch.name];
162
169
  // Bind-time `'chord.'` lint (design.md §5.6, best-effort backstop —
163
170
  // the staging facade is the wall): the loader-private state namespace
164
171
  // is off-limits to hatches; a quoted literal fails the bind atomically,
165
172
  // like a missing export. The devkit source lint is the authoritative
166
173
  // layer (this one can miss minified code and can trip on a quoted
167
174
  // literal inside a compiled-in comment — reword the comment).
168
- const chordLiteral = (0, hatch_context_1.findChordLiteral)(bound);
175
+ const chordLiteral = (0, hatch_context_js_1.findChordLiteral)(bound);
169
176
  if (chordLiteral !== null) {
170
- throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` references the loader-private \`chord.*\` state namespace (\`${chordLiteral}\`) — hatches read the world through their context only (design.md §5.6). If the match is inside a comment, reword it.`, hatch.span);
177
+ throw new errors_js_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` references the loader-private \`chord.*\` state namespace (\`${chordLiteral}\`) — hatches read the world through their context only (design.md §5.6). If the match is inside a comment, reword it.`, hatch.span);
171
178
  }
172
179
  const kind = hatch.hatchKind ?? 'text';
173
180
  switch (kind) {
174
181
  case 'text': {
175
182
  if (typeof bound !== 'function') {
176
- throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not a function'} — expected a dynamic-text producer export.`, hatch.span);
183
+ throw new errors_js_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not a function'} — expected a dynamic-text producer export.`, hatch.span);
177
184
  }
178
185
  this.producers.set(hatch.name, bound);
179
186
  break;
@@ -182,48 +189,130 @@ class ChordStory {
182
189
  // Interface Contract 3: the export IS a four-phase Action.
183
190
  const action = bound;
184
191
  if (!action || typeof action !== 'object' || typeof action.validate !== 'function' || typeof action.execute !== 'function') {
185
- throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not an Action'} — expected a four-phase Action export (validate/execute/report/blocked).`, hatch.span);
192
+ throw new errors_js_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not an Action'} — expected a four-phase Action export (validate/execute/report/blocked).`, hatch.span);
186
193
  }
187
194
  this.boundActions.set(hatch.name, bound);
188
195
  break;
189
196
  }
190
- case 'behavior': {
191
- const behavior = bound;
192
- if (!behavior || typeof behavior !== 'object' || typeof behavior.validate !== 'function' || typeof behavior.execute !== 'function' || typeof behavior.report !== 'function') {
193
- throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not a CapabilityBehavior'} — expected a validate/execute/report export.`, hatch.span);
197
+ case 'chain': {
198
+ // ADR-094: the export IS an EventChainHandler; registered in
199
+ // initializeWorld to REPLACE the stdlib chain (same key).
200
+ if (typeof bound !== 'function') {
201
+ throw new errors_js_1.LoadError(`Chain hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not a function'} — expected an EventChainHandler (the module's default export).`, hatch.span);
194
202
  }
195
- this.boundBehaviors.set(hatch.name, bound);
203
+ this.boundChains.set(hatch.name, bound);
196
204
  break;
197
205
  }
206
+ // The `behavior` hatch kind was removed (ADR-235 D2, 2026-07-18) —
207
+ // it carried no binding key and could never fire; the compiler now
208
+ // refuses the declaration outright.
198
209
  }
199
210
  }
200
211
  }
201
212
  // ------------------------------------------------------------ lifecycle
202
213
  initializeWorld(world) {
203
214
  this.world = world;
215
+ // ADR-094 chain hatches: register each replacement handler under its stdlib
216
+ // chain key. `registerStandardChains` ran at engine init (before setStory →
217
+ // initializeWorld), so a same-key `chainEvent` REPLACES the stdlib default
218
+ // in place. Idempotent across restart (keyed replacement).
219
+ for (const [alias, handler] of this.boundChains) {
220
+ const reg = (0, chain_map_js_1.resolveChain)(alias);
221
+ if (!reg) {
222
+ // The chord analyzer's `analysis.unknown-chain` gate catches this first;
223
+ // this backstops rogue IR reaching the loader.
224
+ throw new errors_js_1.LoadError(`Chain hatch \`${alias}\` names no known stdlib chain.`);
225
+ }
226
+ world.chainEvent(reg.trigger, handler, { key: reg.key, priority: reg.priority });
227
+ }
228
+ // ADR-215: `use`-declared trusted extensions register FIRST — their
229
+ // world-side registrations (interceptors, resolvers) must exist before
230
+ // any entity composes their vocabulary. Unknown names are LoadErrors
231
+ // (the compiler's manifest gate catches them first; this backstops
232
+ // rogue IR).
233
+ for (const name of this.ir.uses ?? []) {
234
+ const registration = extension_registry_js_1.EXTENSION_REGISTRY.get(name);
235
+ if (!registration) {
236
+ throw new errors_js_1.LoadError(`\`use ${name}\` names no trusted extension — known: ${[...extension_registry_js_1.EXTENSION_REGISTRY.keys()].join(', ')}.`);
237
+ }
238
+ registration.registerWorld?.(world);
239
+ }
204
240
  const built = [];
205
- // Pass 1create every non-player entity.
241
+ // Pass 0regions, parents before children (ADR-236 D3): a nested
242
+ // region's `parentRegionId` is validated by `createRegion` at creation,
243
+ // so the parent's world entity must already exist.
244
+ for (const irEntity of this.regionsInParentFirstOrder()) {
245
+ built.push({ ir: irEntity, entity: this.buildEntity(world, irEntity) });
246
+ }
247
+ // Pass 1 — create every remaining non-player entity.
206
248
  for (const irEntity of this.ir.entities) {
207
- if (irEntity.isPlayer)
249
+ if (irEntity.isPlayer || this.worldIds.has(irEntity.id))
208
250
  continue;
209
251
  built.push({ ir: irEntity, entity: this.buildEntity(world, irEntity) });
210
252
  }
211
253
  // Pass 2 — placement, exits, blocked exits, initial states.
254
+ // Placement is WORLD CONSTRUCTION, not a runtime action: it goes through
255
+ // AuthorModel.moveEntity, which bypasses the runtime containment rules
256
+ // (a closed trunk can hold its contents at load — the plain
257
+ // world.moveEntity refusal was a silent drop; David-approved fix,
258
+ // 2026-07-18, matching the TS story path's established pattern).
259
+ const author = new world_model_1.AuthorModel(world.getDataStore(), world);
212
260
  for (const { ir: irEntity, entity } of built) {
213
261
  if (irEntity.placement && irEntity.placement.relation !== 'starts-in') {
214
- world.moveEntity(entity.id, this.requireWorldId(irEntity.placement.place, irEntity));
262
+ author.moveEntity(entity.id, this.requireWorldId(irEntity.placement.place, irEntity));
215
263
  }
216
- for (const exit of irEntity.exits) {
217
- world.connectRooms(entity.id, this.requireWorldId(exit.to, irEntity), toDirection(exit.direction, irEntity));
264
+ // ADR-236 D2: region membership through the platform seam —
265
+ // `assignRoom` sets RoomTrait.regionId (never touched directly here);
266
+ // member regions were parented at creation (pass 0), so only room
267
+ // members remain to wire.
268
+ for (const member of irEntity.containing ?? []) {
269
+ const memberIr = this.ir.entities.find((e) => e.id === member.id);
270
+ if (memberIr && memberIr.kinds.some((k) => k.name === 'room')) {
271
+ world.assignRoom(this.requireWorldId(member.id, irEntity), entity.id);
272
+ }
218
273
  }
219
- for (const blocked of irEntity.blockedExits) {
220
- // Unconditional blocks are static; `is blocked while <cond>` blocks
221
- // are derived the runtime recomputes them with dark-while (grammar
222
- // log 2026-07-10; initial evaluation at player finalization).
223
- if (blocked.condition === null) {
224
- world_model_1.RoomBehavior.blockExit(entity, toDirection(blocked.direction, irEntity), this.phraseText(blocked.phraseKey));
274
+ for (const exit of irEntity.exits) {
275
+ const toId = this.requireWorldId(exit.to, irEntity);
276
+ const direction = toDirection(exit.direction, irEntity);
277
+ if (exit.via === null) {
278
+ // Defensive (Phase 8 #6, belt-and-suspenders with the analyzer's
279
+ // door-plain-mirror gate): connectRooms stamps BOTH directions, so
280
+ // a plain exit whose reverse side is already door-wired would
281
+ // silently unwire that door. The compiler refuses this; reaching
282
+ // here means rogue IR.
283
+ const targetRoomTrait = world.getEntity(toId)?.get(world_model_1.TraitType.ROOM);
284
+ const reverseExit = targetRoomTrait?.exits?.[(0, world_model_1.getOppositeDirection)(direction)];
285
+ if (reverseExit?.via && reverseExit.destination === entity.id) {
286
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: plain \`${exit.direction}\` exit mirrors a door-wired exit on \`${exit.to}\` — rogue IR (the compiler's door-plain-mirror gate refuses this).`, exit.span);
287
+ }
288
+ world.connectRooms(entity.id, toId, direction);
289
+ continue;
290
+ }
291
+ // ADR-234 D1/D2 via ADR-237 D4: the door exit wires through the
292
+ // one platform primitive — DoorTrait attached here (room1 = the
293
+ // declaring room, per the createDoor placement convention), then
294
+ // connectRooms stamps `via` both directions and places the door.
295
+ // A door wires exactly once: the analyzer's checkDoors gates
296
+ // guarantee any second reference is the exact mirror, whose exits
297
+ // the first wiring already stamped — verified, then skipped.
298
+ const doorId = this.requireWorldId(exit.via, irEntity);
299
+ const door = world.getEntity(doorId);
300
+ const doorTrait = door?.get(world_model_1.TraitType.DOOR);
301
+ if (doorTrait) {
302
+ const isMirror = doorTrait.room1 === toId && doorTrait.room2 === entity.id;
303
+ if (!isMirror) {
304
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: door \`${exit.via}\` is already wired to a different room pair — rogue IR (the compiler's door gates refuse this).`, exit.span);
305
+ }
306
+ continue;
225
307
  }
308
+ door?.add(new world_model_1.DoorTrait({ room1: entity.id, room2: toId }));
309
+ world.connectRooms(entity.id, toId, direction, doorId);
226
310
  }
311
+ // Blocked exits — ADR-240 D2 (Option A): ALL of them, conditional and
312
+ // unconditional alike, are registered as live evaluators by the
313
+ // runtime's bind(); nothing is stamped onto RoomTrait.blockedExits
314
+ // here anymore (the trait map remains the hand-written TS stories'
315
+ // surface, consulted by going as the fall-through).
227
316
  // ADR-227: `deadly: <phrase>` — the no-escape room marker lowers to
228
317
  // DeadlyRoomTrait (safeVerbs default look/examine); the ENGINE
229
318
  // auto-registers the deadly-room transformer, so no runtime code here.
@@ -238,7 +327,7 @@ class ChordStory {
238
327
  // to one command transformer in onEngineReady.
239
328
  for (const deadly of irEntity.deadlyExits) {
240
329
  if (deadly.condition !== null) {
241
- throw new errors_1.LoadError('`is deadly while <condition>` is not wired yet — the conditional deadly exit is post-scope (mirror: role-bound trait clauses). Use an unconditional `is deadly:` or an `on going` clause with `kill the player when <condition>`.', deadly.span);
330
+ throw new errors_js_1.LoadError('`is deadly while <condition>` is not wired yet — the conditional deadly exit is post-scope (mirror: role-bound trait clauses). Use an unconditional `is deadly:` or an `on going` clause with `kill the player when <condition>`.', deadly.span);
242
331
  }
243
332
  const direction = toDirection(deadly.direction, irEntity);
244
333
  const byRoom = this.deadlyExits.get(entity.id) ?? new Map();
@@ -246,7 +335,7 @@ class ChordStory {
246
335
  this.deadlyExits.set(entity.id, byRoom);
247
336
  }
248
337
  if (irEntity.states.length > 0) {
249
- world.setStateValue(state_keys_1.CHORD_STATE_PREFIX + irEntity.id, irEntity.states[0]);
338
+ world.setStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irEntity.id, irEntity.states[0]);
250
339
  }
251
340
  // Z2 (ADR-211): compile `{key}` description markers onto ADR-209
252
341
  // snippet storage — atomically per room, before the engine's
@@ -255,9 +344,17 @@ class ChordStory {
255
344
  this.compileRoomSnippets(world, irEntity, entity);
256
345
  }
257
346
  }
347
+ // ADR-234 D3 backstop (rogue IR — the compiler's `door-unconnected`
348
+ // gate refuses this): a door no `through` exit wired has no room pair
349
+ // and could never resolve in play.
350
+ for (const { ir: irEntity, entity } of built) {
351
+ if (irEntity.kinds.some((k) => k.name === 'door') && !entity.has(world_model_1.TraitType.DOOR)) {
352
+ throw new errors_js_1.LoadError(`Door \`${irEntity.name}\` was never wired by a \`through\` exit line — rogue IR (the compiler's door gates refuse this).`, irEntity.span);
353
+ }
354
+ }
258
355
  // The story object starts in its first declared phase (ratchet D2).
259
356
  if (this.ir.story.states.length > 0) {
260
- world.setStateValue(state_keys_1.CHORD_STORY_STATE_KEY, this.ir.story.states[0]);
357
+ world.setStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY, this.ir.story.states[0]);
261
358
  }
262
359
  // Declared scores set the ceiling (dedup-by-identity makes the sum
263
360
  // exact — ADR-129).
@@ -329,6 +426,17 @@ class ChordStory {
329
426
  if (startIr) {
330
427
  world.moveEntity(player.id, this.requireWorldId(startIr, irPlayer ?? undefined));
331
428
  }
429
+ // Player-block composition (Gap-2 ruling, David 2026-07-18): the
430
+ // player composes trait adjectives + `starts` states through the SAME
431
+ // loader path as any entity (`a person` is analyzer-gated to a no-op;
432
+ // NPC behaviors are refused at compile). Runs here — the second
433
+ // lifecycle hook — so entity-valued configs resolve against the
434
+ // fully-built world regardless of createPlayer/initializeWorld order.
435
+ if (irPlayer) {
436
+ this.applyTraitAdjectives(player, irPlayer, null);
437
+ this.applyStartsStates(player, irPlayer);
438
+ this.resolvePendingEntityRefs();
439
+ }
332
440
  // Carried items: into inventory (ADR-230 Phase 6 — `carries the X`).
333
441
  for (const carriedIrId of irPlayer?.carries ?? []) {
334
442
  world.moveEntity(this.requireWorldId(carriedIrId, irPlayer ?? undefined), player.id);
@@ -340,14 +448,13 @@ class ChordStory {
340
448
  const worn = world.getEntity(wornId);
341
449
  const wearable = worn?.get(world_model_1.TraitType.WEARABLE);
342
450
  if (!wearable) {
343
- throw new errors_1.LoadError(`\`${wornIrId}\` is worn by the player but is not wearable.`, irPlayer?.span);
451
+ throw new errors_js_1.LoadError(`\`${wornIrId}\` is worn by the player but is not wearable.`, irPlayer?.span);
344
452
  }
345
453
  wearable.worn = true;
346
454
  wearable.wornBy = player.id;
347
455
  }
348
- // Initial evaluation of derived properties (`dark while`) the player
349
- // and their possessions now exist, so conditions are decidable.
350
- this.runtime.recomputeDerived(world);
456
+ // ADR-240: no initial derived-property evaluation derived state is
457
+ // registered evaluators, consulted live at every read.
351
458
  }
352
459
  extendLanguage(language) {
353
460
  // `addMessage` is the concrete providers' registration surface
@@ -355,12 +462,23 @@ class ChordStory {
355
462
  // structurally so the loader stays locale-neutral.
356
463
  const registry = language;
357
464
  if (typeof registry.addMessage !== 'function') {
358
- throw new errors_1.LoadError('The language provider does not support message registration (addMessage).');
465
+ throw new errors_js_1.LoadError('The language provider does not support message registration (addMessage).');
359
466
  }
360
467
  const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
361
468
  for (const [key, phrase] of Object.entries(table)) {
362
469
  registry.addMessage(key, templateFor(phrase));
363
470
  }
471
+ // ADR-242 D7: declared pronoun sets ride the same seam — the same
472
+ // structural probe, throwing a legible error only when a story
473
+ // actually declares sets the provider cannot take.
474
+ if (this.ir.pronounSets.length > 0) {
475
+ if (typeof registry.registerPronounSet !== 'function') {
476
+ throw new errors_js_1.LoadError('The language provider does not support pronoun-set registration (registerPronounSet).');
477
+ }
478
+ for (const set of this.ir.pronounSets) {
479
+ registry.registerPronounSet(set.name, set.forms);
480
+ }
481
+ }
364
482
  }
365
483
  getCustomVocabulary() {
366
484
  return { verbs: this.ir.verbs.map((v) => toVocabularyVerb(v)) };
@@ -379,6 +497,37 @@ class ChordStory {
379
497
  * state is world state — no runner-state plumbing (design.md §6).
380
498
  */
381
499
  onEngineReady(engine) {
500
+ // ADR-216 `client has`: wire the LIVE capability source (the engine
501
+ // negotiates capabilities at start(); reads happen per evaluation).
502
+ // Engines without the accessor leave the text-only default in place.
503
+ if (engine.getClientCapabilities) {
504
+ this.evaluator.setCapabilitiesProvider(() => engine.getClientCapabilities());
505
+ }
506
+ // ADR-215 Q4: NPCs are CORE — the plugin auto-wires unconditionally
507
+ // (unlike the scheduler's daemon-gated registration below), and each
508
+ // factory-configured behavior registers under its per-entity id.
509
+ const npcPlugin = new plugin_npc_1.NpcPlugin();
510
+ engine.getPluginRegistry().register(npcPlugin);
511
+ const npcService = npcPlugin.getNpcService();
512
+ for (const pending of this.npcBehaviors) {
513
+ npcService.registerBehavior(this.buildNpcBehavior(pending));
514
+ }
515
+ // ADR-215 `use state-machines`: the plugin registers engine-side and
516
+ // every `define machine` lowers into its registry (Chord conditions
517
+ // ride as custom guards, Chord bodies as custom effects). Machines in
518
+ // rogue IR without the `use` are a LoadError, never silently dead.
519
+ if ((this.ir.machines ?? []).length > 0 && !(this.ir.uses ?? []).includes('state-machines')) {
520
+ throw new errors_js_1.LoadError('`define machine` needs `use state-machines` in the story header.', this.ir.machines[0].span);
521
+ }
522
+ if ((this.ir.uses ?? []).includes('state-machines')) {
523
+ const smPlugin = new plugin_state_machine_1.StateMachinePlugin();
524
+ engine.getPluginRegistry().register(smPlugin);
525
+ const smRegistry = smPlugin.getRegistry();
526
+ for (const machine of this.ir.machines ?? []) {
527
+ const { definition, bindings } = this.buildMachineDefinition(machine);
528
+ smRegistry.register(definition, bindings);
529
+ }
530
+ }
382
531
  const daemons = this.runtime.buildSchedulerDaemons();
383
532
  if (daemons.length > 0) {
384
533
  const plugin = new plugin_scheduler_1.SchedulerPlugin();
@@ -399,6 +548,58 @@ class ChordStory {
399
548
  // built-in contributor evaluates them.
400
549
  this.registerPresentEntries(engine);
401
550
  }
551
+ /**
552
+ * ADR-216 custom channels + ADR-215's third contribution part: every
553
+ * `define channel` lowers to a real IOChannel (JSON data projection —
554
+ * the turn's last event of the declared type, `take` fields projected
555
+ * from its data), and every `use`d extension gets its reserved
556
+ * `registerChannels` slot invoked (no bundled extension registers one
557
+ * today — the leg is live but unexercised; a novel renderer would ship
558
+ * there, keeping stories pure IR). The engine invokes this hook once at
559
+ * start (`Story.registerChannels`, engine/src/game-engine.ts).
560
+ *
561
+ * ADR-241 D4: family channels (`define ambient`/`define layer`, plus
562
+ * the implied `main` bed) register through stdlib's family builders —
563
+ * `ambient:<word>` / `image:<word>` ids, capability gates inherited
564
+ * from the builders (`sound` / `images`). Data channels are untouched.
565
+ */
566
+ registerChannels(registry) {
567
+ for (const channel of this.ir.channels ?? []) {
568
+ if (channel.family !== 'data') {
569
+ registry.add(channel.family === 'ambient'
570
+ ? (0, stdlib_1.createAmbientChannel)(channel.name)
571
+ : (0, stdlib_1.createImageChannel)(channel.name));
572
+ continue;
573
+ }
574
+ const { returns } = channel;
575
+ // ADR-256: the IR `fromEvent` is a dotless Chord id; match against the
576
+ // platform runtime type (media.* → dotted; author events pass through),
577
+ // the same translation the emit seam applies, so the two always agree.
578
+ const fromEvent = (0, event_id_map_js_1.translateEventId)(channel.fromEvent);
579
+ const definition = {
580
+ id: channel.name,
581
+ contentType: 'json',
582
+ mode: channel.mode,
583
+ emit: 'sparse',
584
+ ...(channel.gatedBy ? { gatedBy: channel.gatedBy } : {}),
585
+ produce: (ctx) => {
586
+ for (let i = ctx.events.length - 1; i >= 0; i--) {
587
+ const event = ctx.events[i];
588
+ if (event.type !== fromEvent)
589
+ continue;
590
+ const data = (event.data ?? {});
591
+ const value = this.evaluateChannelReturn(returns, data);
592
+ return channel.mode === 'append' ? [value] : value;
593
+ }
594
+ return undefined;
595
+ },
596
+ };
597
+ registry.add(definition);
598
+ }
599
+ for (const name of this.ir.uses ?? []) {
600
+ extension_registry_js_1.EXTENSION_REGISTRY.get(name)?.registerChannels?.(registry);
601
+ }
602
+ }
402
603
  /**
403
604
  * The deadly-exit command transformer (ADR-227). Redirects `going
404
605
  * <deadly-direction>` from a room with declared deadly exits to
@@ -468,13 +669,13 @@ class ChordStory {
468
669
  const ownerWorldId = this.worldIds.get(irEntity.id);
469
670
  if (!ownerWorldId)
470
671
  continue;
471
- const texts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_1.withLineBreaks)(v.text)));
672
+ const texts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_js_1.withLineBreaks)(v.text)));
472
673
  const content = texts.length === 1 && !phrase.strategy
473
674
  ? { kind: 'literal', text: texts[0] }
474
675
  : {
475
676
  kind: 'choice',
476
677
  alternatives: texts.map((text) => ({ kind: 'literal', text })),
477
- selector: runtime_1.STRATEGY_SELECTOR[phrase.strategy ?? 'cycling'],
678
+ selector: runtime_js_1.STRATEGY_SELECTOR[phrase.strategy ?? 'cycling'],
478
679
  // ADR-212 §4 caller contract: entityId = owner, messageKey =
479
680
  // counterKey — the platform warns on a mismatch.
480
681
  entityId: ownerWorldId,
@@ -512,6 +713,14 @@ class ChordStory {
512
713
  extendParser(parser) {
513
714
  const grammar = parser.getStoryGrammar();
514
715
  for (const action of this.ir.actions) {
716
+ // Bare-verb forms (platform-issue-sweep Phase 8 #13, David's ruling:
717
+ // ALL dispatch actions): dispatch validate fully handles the no-target
718
+ // case (authored `refuse without` arm, else the platform default), but
719
+ // compiler-emitted grammar always carried the slot, so a bare `lower`
720
+ // (or friendly-zoo's `pet`/`feed`) never parsed far enough to reach it.
721
+ // Register each pattern's literal prefix as its own rule, below the
722
+ // slotted forms so they win whenever a target is named.
723
+ const bareForms = new Set();
515
724
  for (const pattern of action.patterns) {
516
725
  if (pattern.cardinality)
517
726
  continue; // `→ each …` expansion is engine-owned (Phase C)
@@ -519,6 +728,18 @@ class ChordStory {
519
728
  .map((part) => (part.kind === 'slot' ? `:${part.word}` : part.word))
520
729
  .join(' ');
521
730
  grammar.define(text).mapsTo(`chord.action.${action.name}`).withPriority(150).build();
731
+ const slotIndex = pattern.parts.findIndex((part) => part.kind === 'slot');
732
+ if (slotIndex > 0) {
733
+ const bare = pattern.parts
734
+ .slice(0, slotIndex)
735
+ .map((part) => part.word)
736
+ .join(' ');
737
+ if (bare)
738
+ bareForms.add(bare);
739
+ }
740
+ }
741
+ for (const bare of bareForms) {
742
+ grammar.define(bare).mapsTo(`chord.action.${action.name}`).withPriority(140).build();
522
743
  }
523
744
  }
524
745
  }
@@ -541,87 +762,181 @@ class ChordStory {
541
762
  };
542
763
  }
543
764
  // ------------------------------------------------------- entity build
765
+ /** Region IR id → parent region IR id (the parent's `containing` lists the child — ADR-236 D3). */
766
+ regionParents = new Map();
767
+ /**
768
+ * Region entities in parent-first order, filling `regionParents` on the
769
+ * way (ADR-236 D3). The compiler's cycle gate guarantees the walk ends; a
770
+ * cycle here means rogue IR and is a LoadError, never a hang.
771
+ */
772
+ regionsInParentFirstOrder() {
773
+ const regions = this.ir.entities.filter((e) => !e.isPlayer && e.kinds.some((k) => k.name === 'region'));
774
+ const byId = new Map(regions.map((r) => [r.id, r]));
775
+ this.regionParents.clear();
776
+ for (const region of regions) {
777
+ for (const member of region.containing ?? []) {
778
+ if (byId.has(member.id))
779
+ this.regionParents.set(member.id, region.id);
780
+ }
781
+ }
782
+ const ordered = [];
783
+ const state = new Map();
784
+ const visit = (region) => {
785
+ const s = state.get(region.id);
786
+ if (s === 'done')
787
+ return;
788
+ if (s === 'visiting') {
789
+ throw new errors_js_1.LoadError(`Region containment cycle at \`${region.name}\` — the compiler gate should have refused this story.`, region.span);
790
+ }
791
+ state.set(region.id, 'visiting');
792
+ const parentId = this.regionParents.get(region.id);
793
+ const parent = parentId ? byId.get(parentId) : undefined;
794
+ if (parent)
795
+ visit(parent);
796
+ state.set(region.id, 'done');
797
+ ordered.push(region);
798
+ };
799
+ for (const region of regions)
800
+ visit(region);
801
+ return ordered;
802
+ }
544
803
  buildEntity(world, irEntity) {
545
804
  if (irEntity.kinds.length > 1) {
546
- throw new errors_1.LoadError(`\`${irEntity.name}\` declares more than one kind noun.`, irEntity.span);
805
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\` declares more than one kind noun.`, irEntity.span);
547
806
  }
548
807
  const kind = irEntity.kinds[0]?.name ?? null;
549
808
  const description = irEntity.descriptionKey ? this.phraseText(irEntity.descriptionKey) : undefined;
550
- const h = (0, helpers_1.createHelpers)(world);
809
+ // ADR-237 D3: direct trait composition — the loader builds on the
810
+ // world-model surface itself; `@sharpee/helpers` is author-facing only.
811
+ const aliases = irEntity.aka.length ? irEntity.aka : undefined;
551
812
  let entity;
552
813
  switch (kind) {
553
814
  case 'room': {
554
- const builder = h.room(irEntity.name);
555
- if (description)
556
- builder.description(description);
557
815
  // Z1: `first time` prose → RoomTrait.initialDescription (first look
558
816
  // shows it, later looks show the standard description — stdlib's
559
817
  // looking-data reads the field; no stdlib change).
560
818
  const initialDescription = irEntity.initialDescriptionKey
561
819
  ? this.phraseText(irEntity.initialDescriptionKey)
562
820
  : undefined;
563
- if (initialDescription)
564
- builder.initialDescription(initialDescription);
565
- if (irEntity.aka.length)
566
- builder.aliases(...irEntity.aka);
567
- if (irEntity.traits.some((t) => t.name === 'dark' && t.condition === null))
568
- builder.dark();
569
- entity = builder.build();
821
+ entity = world.createEntity(irEntity.name, 'room');
822
+ entity.add(new world_model_1.RoomTrait({
823
+ requiresLight: irEntity.traits.some((t) => t.name === 'dark' && t.condition === null),
824
+ initialDescription,
825
+ }));
826
+ entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
570
827
  break;
571
828
  }
572
829
  case 'container': {
573
- const builder = h.container(irEntity.name);
574
- if (description)
575
- builder.description(description);
576
- if (irEntity.aka.length)
577
- builder.aliases(...irEntity.aka);
578
- if (irEntity.traits.some((t) => t.name === 'openable'))
579
- builder.openable();
580
- // NOTE: no `builder.lockable()` pre-add — applyTraitAdjectives owns
581
- // the lockable composition so `with key X` config is never dropped
582
- // (ADR-230 Phase 9a: the keyless pre-add made the keyed re-add skip).
583
- entity = builder.build();
830
+ // NOTE: no OpenableTrait/LockableTrait pre-adds — applyTraitAdjectives
831
+ // owns both compositions uniformly. For lockable, a keyless pre-add
832
+ // made the keyed re-add skip, dropping `with key X` config (ADR-230
833
+ // Phase 9a). For openable, the pre-add carried an open-by-default,
834
+ // splitting container-kind entities from adjective-only ones; ADR-231
835
+ // D5b removed it so OpenableTrait's default (closed) is authoritative
836
+ // everywhere — `starts open` is the author's escape hatch.
837
+ entity = world.createEntity(irEntity.name, 'object');
838
+ entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
839
+ entity.add(new world_model_1.ContainerTrait());
584
840
  this.applyContainerConfig(entity, irEntity.kinds[0]);
585
841
  break;
586
842
  }
587
843
  case 'person': {
588
- const builder = h.actor(irEntity.name);
589
- if (description)
590
- builder.description(description);
591
- if (irEntity.aka.length)
592
- builder.aliases(...irEntity.aka);
593
- entity = builder.build();
844
+ entity = world.createEntity(irEntity.name, 'actor');
845
+ entity.add(new world_model_1.ActorTrait());
846
+ // ADR-242 D2/D3: `proper` → the player's own proper-name shape
847
+ // (properName + empty article); otherwise the plain IdentityTrait
848
+ // defaults stand ('a', contextual articles). The old helpers-era
849
+ // `article: undefined` pin is gone — no loader path constructs an
850
+ // undefined article. `pronouns` maps to pronounSet only when
851
+ // declared (ruled Q-2: no injected default — by-number fallback).
852
+ const proper = irEntity.traits.some((t) => t.name === 'proper' && t.condition === null);
853
+ entity.add(new world_model_1.IdentityTrait({
854
+ name: irEntity.name,
855
+ description,
856
+ aliases,
857
+ ...(proper ? { properName: true, article: '' } : {}),
858
+ ...(irEntity.pronouns !== undefined ? { pronounSet: irEntity.pronouns } : {}),
859
+ }));
594
860
  break;
595
861
  }
596
862
  case 'supporter': {
597
- const builder = h.object(irEntity.name);
598
- if (description)
599
- builder.description(description);
600
- if (irEntity.aka.length)
601
- builder.aliases(...irEntity.aka);
602
- entity = builder.build();
863
+ entity = world.createEntity(irEntity.name, 'object');
864
+ entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
603
865
  entity.add(new world_model_1.SupporterTrait({ capacity: supporterCapacity(irEntity.kinds[0]) }));
604
866
  break;
605
867
  }
606
- case 'door':
607
- throw new errors_1.LoadError(`\`${irEntity.name}\`: doors need \`between\` placement, which the Phase A loader does not support yet.`, irEntity.span);
868
+ case 'region': {
869
+ // ADR-236 D1: built on the shipped platform seam createRegion +
870
+ // assignRoom ARE the shared mechanics (RoomTrait.regionId is never
871
+ // set directly). Pass 0 built parents first, so a nested child's
872
+ // parentRegionId resolves here.
873
+ const parentIr = this.regionParents.get(irEntity.id);
874
+ const parentRegionId = parentIr ? this.worldIds.get(parentIr) : undefined;
875
+ entity = world.createRegion(`rg-${irEntity.id}`, {
876
+ name: irEntity.name,
877
+ ...(parentRegionId ? { parentRegionId } : {}),
878
+ });
879
+ // A region block composes like any entity block (aka, description):
880
+ // both live on IdentityTrait; whether any action surfaces them is
881
+ // the platform's business (ADR-236 D1).
882
+ entity.add(new world_model_1.IdentityTrait({
883
+ name: irEntity.name,
884
+ ...(description ? { description } : {}),
885
+ aliases: irEntity.aka,
886
+ article: irEntity.article ?? 'the',
887
+ }));
888
+ break;
889
+ }
890
+ case 'door': {
891
+ // ADR-234 D4: SceneryTrait + OpenableTrait starting closed compose
892
+ // automatically (createDoor parity; `starts open` is the author's
893
+ // override via applyStartsStates). DoorTrait is attached at exit
894
+ // wiring — its room pair comes from the `through` exit line, and
895
+ // the trait's constructor requires both rooms.
896
+ entity = world.createEntity(irEntity.name, 'door');
897
+ entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
898
+ entity.add(new world_model_1.SceneryTrait());
899
+ entity.add(new world_model_1.OpenableTrait({ isOpen: false }));
900
+ break;
901
+ }
608
902
  case null: {
609
- const builder = h.object(irEntity.name);
610
- if (description)
611
- builder.description(description);
612
- if (irEntity.aka.length)
613
- builder.aliases(...irEntity.aka);
614
- entity = builder.build();
903
+ entity = world.createEntity(irEntity.name, 'object');
904
+ entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
615
905
  break;
616
906
  }
617
907
  default:
618
- throw new errors_1.LoadError(`\`${irEntity.name}\`: unknown kind noun \`${kind}\`.`, irEntity.span);
908
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: unknown kind noun \`${kind}\`.`, irEntity.span);
619
909
  }
620
910
  this.applyTraitAdjectives(entity, irEntity, kind);
911
+ this.applyStartsStates(entity, irEntity);
621
912
  this.worldIds.set(irEntity.id, entity.id);
622
913
  this.irIds.set(entity.id, irEntity.id);
623
914
  return entity;
624
915
  }
916
+ /**
917
+ * ADR-231 D5a: map each accepted `starts <state>` initializer to the
918
+ * paired trait's initial-value field (locked→isLocked:true, closed→
919
+ * isOpen:false, on→isOn:true, …). Runs AFTER trait composition (the
920
+ * adjective-composed traits, ADR-231 D5b: there are no builder pre-adds
921
+ * left) — so a declared initializer always wins over any
922
+ * trait default. Only the trait boolean is set; the state adjective
923
+ * itself is never stored story state (the shadow-state ratchet).
924
+ * The analyzer's pairing gate guarantees the trait is present; a missing
925
+ * trait here is a defect, reported as a LoadError, never a silent skip.
926
+ */
927
+ applyStartsStates(entity, irEntity) {
928
+ for (const state of irEntity.startsStates ?? []) {
929
+ const mapping = STARTS_STATE_TRAIT_FIELDS.get(state);
930
+ if (!mapping) {
931
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`starts ${state}\` has no trait-field mapping — the compiler and loader tables are out of step.`, irEntity.span);
932
+ }
933
+ const trait = entity.get(mapping.traitType);
934
+ if (!trait) {
935
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`starts ${state}\` needs the \`${mapping.traitName}\` trait composed — the analyzer pairing gate should have refused this story.`, irEntity.span);
936
+ }
937
+ trait[mapping.field] = mapping.value;
938
+ }
939
+ }
625
940
  /**
626
941
  * Resolve trait-config entity references (`with tool X`, `with key X`)
627
942
  * to world entity ids (ADR-230 D3c / Phase 9a) — runs once, after every
@@ -631,7 +946,7 @@ class ChordStory {
631
946
  for (const pending of this.pendingEntityRefs) {
632
947
  const worldId = this.worldIds.get(pending.irRefId);
633
948
  if (!worldId) {
634
- throw new errors_1.LoadError(`\`${pending.ownerName}\`: a trait-config entity reference was never built.`, pending.span);
949
+ throw new errors_js_1.LoadError(`\`${pending.ownerName}\`: a trait-config entity reference was never built.`, pending.span);
635
950
  }
636
951
  pending.apply(worldId);
637
952
  }
@@ -678,10 +993,10 @@ class ChordStory {
678
993
  surfaces++;
679
994
  }
680
995
  if (surfaces === 0) {
681
- throw new errors_1.LoadError(`\`${irEntity.name}\` is ${adjective} but registers no ${gerund} implementation — add \`on ${gerund} it:\` (or compose a trait that has one).`, irEntity.span);
996
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\` is ${adjective} but registers no ${gerund} implementation — add \`on ${gerund} it:\` (or compose a trait that has one).`, irEntity.span);
682
997
  }
683
998
  if (surfaces > 1) {
684
- throw new errors_1.LoadError(`\`${irEntity.name}\` has ${surfaces} ${gerund} implementations — a ${adjective} entity registers exactly one (one \`on ${gerund} it\` clause or one capability behavior).`, irEntity.span);
999
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\` has ${surfaces} ${gerund} implementations — a ${adjective} entity registers exactly one (one \`on ${gerund} it\` clause or one capability behavior).`, irEntity.span);
685
1000
  }
686
1001
  }
687
1002
  }
@@ -698,12 +1013,18 @@ class ChordStory {
698
1013
  continue;
699
1014
  const def = this.ir.traits.find((t) => t.name === trait.name);
700
1015
  if (def && def.onClauses.length > 0 && def.onClauses.every((c) => c.binding === 'every-turn')) {
701
- entity.add(new ChordDataTrait(state_keys_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
1016
+ entity.add(new ChordDataTrait(state_keys_js_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
702
1017
  continue;
703
1018
  }
704
- throw new errors_1.LoadError(`Conditional composition isn't supported for \`${trait.name}\` — move the condition inside the trait (\`on <action> it\` clauses can test it) or split the behavior.`, trait.span);
1019
+ throw new errors_js_1.LoadError(`Conditional composition isn't supported for \`${trait.name}\` — move the condition inside the trait (\`on <action> it\` clauses can test it) or split the behavior.`, trait.span);
705
1020
  }
706
1021
  switch (trait.name) {
1022
+ case 'proper':
1023
+ // ADR-242 D1: identity configuration, consumed by the person
1024
+ // branch's IdentityTrait construction (like the room branch's
1025
+ // `dark`) — without this case it would fall through to the
1026
+ // authored-trait default and mint a spurious ChordDataTrait.
1027
+ break;
707
1028
  case 'scenery':
708
1029
  if (!entity.has(world_model_1.TraitType.SCENERY))
709
1030
  entity.add(new world_model_1.SceneryTrait());
@@ -714,18 +1035,38 @@ class ChordStory {
714
1035
  case 'readable':
715
1036
  entity.add(new world_model_1.ReadableTrait({ text: configValue(trait, 'text') ?? '' }));
716
1037
  break;
717
- case 'openable':
718
- if (!entity.has(world_model_1.TraitType.OPENABLE))
719
- entity.add(new world_model_1.OpenableTrait());
1038
+ case 'openable': {
1039
+ // Defect D3 fix (2026-07-17, ratchet G4): `openable with the
1040
+ // crowbar` (keyless per R3) gates opening on holding the tool
1041
+ // (OpenableTrait.toolId, ADR-230 D3b). Name → world id via the
1042
+ // shared pending mechanism — never the raw display-name string
1043
+ // (the lockable-bug class).
1044
+ if (entity.has(world_model_1.TraitType.OPENABLE))
1045
+ break;
1046
+ const openable = new world_model_1.OpenableTrait();
1047
+ const openToolName = entityConfigValue(trait);
1048
+ if (openToolName !== undefined) {
1049
+ this.pendingEntityRefs.push(this.entityRefFor(openToolName, 'tool', irEntity, trait.span, (worldId) => {
1050
+ openable.toolId = worldId;
1051
+ }));
1052
+ }
1053
+ entity.add(openable);
720
1054
  break;
1055
+ }
721
1056
  case 'lockable': {
722
- // ADR-230 Phase 9a: `with key X` resolves name world id through
723
- // the shared pending mechanism (forward refs legal) the raw
724
- // display-name string never reaches LockableTrait.keyId.
1057
+ // ADR-230 Phase 9a: the key entity (`lockable with the iron key`,
1058
+ // keyless per R3) resolves name world id through the shared
1059
+ // pending mechanism (forward refs legal) — the raw display-name
1060
+ // string never reaches LockableTrait.keyId.
725
1061
  if (entity.has(world_model_1.TraitType.LOCKABLE))
726
1062
  break;
727
- const lockable = new world_model_1.LockableTrait({});
728
- const keyName = configValue(trait, 'key');
1063
+ // ADR-234 D4 kind-scoped default: a lockable DOOR starts locked
1064
+ // (the IF convention; createDoor's `isLocked ?? true` parity)
1065
+ // everywhere else the trait-wide default (unlocked) stands.
1066
+ // `starts unlocked` is the author's override: applyStartsStates
1067
+ // runs after composition, so a declared initializer always wins.
1068
+ const lockable = new world_model_1.LockableTrait(kind === 'door' ? { isLocked: true } : {});
1069
+ const keyName = entityConfigValue(trait);
729
1070
  if (keyName !== undefined) {
730
1071
  this.pendingEntityRefs.push(this.entityRefFor(keyName, 'key', irEntity, trait.span, (worldId) => {
731
1072
  lockable.keyId = worldId;
@@ -743,7 +1084,7 @@ class ChordStory {
743
1084
  if (entity.has(traitType))
744
1085
  break;
745
1086
  const toolGated = trait.name === 'cuttable' ? new world_model_1.CuttableTrait() : new world_model_1.DiggableTrait();
746
- const toolName = configValue(trait, 'tool');
1087
+ const toolName = entityConfigValue(trait);
747
1088
  if (toolName !== undefined) {
748
1089
  this.pendingEntityRefs.push(this.entityRefFor(toolName, 'tool', irEntity, trait.span, (worldId) => {
749
1090
  toolGated.toolId = worldId;
@@ -756,8 +1097,56 @@ class ChordStory {
756
1097
  entity.add(new world_model_1.SwitchableTrait());
757
1098
  break;
758
1099
  case 'edible':
759
- entity.add(new world_model_1.EdibleTrait());
1100
+ // Guarded so `edible, drinkable` composes order-independently —
1101
+ // a bare re-add here would drop drinkable's liquid flag.
1102
+ if (!entity.has(world_model_1.TraitType.EDIBLE))
1103
+ entity.add(new world_model_1.EdibleTrait());
1104
+ break;
1105
+ case 'drinkable': {
1106
+ // Ratchet G1 (2026-07-17): the liquid marker — EDIBLE.liquid is
1107
+ // what routes the entity to drinking instead of eating.
1108
+ const edible = entity.get(world_model_1.TraitType.EDIBLE);
1109
+ if (edible)
1110
+ edible.liquid = true;
1111
+ else
1112
+ entity.add(new world_model_1.EdibleTrait({ liquid: true }));
1113
+ break;
1114
+ }
1115
+ case 'pushable':
1116
+ // Defect D1 fix (2026-07-17): the catalog accepted these two but
1117
+ // the loader had no case — `--check` passed stories that load
1118
+ // rejected. Default config (button-style, repeatable).
1119
+ if (!entity.has(world_model_1.TraitType.PUSHABLE))
1120
+ entity.add(new world_model_1.PushableTrait({}));
1121
+ break;
1122
+ case 'pullable':
1123
+ if (!entity.has(world_model_1.TraitType.PULLABLE))
1124
+ entity.add(new world_model_1.PullableTrait({}));
1125
+ break;
1126
+ case 'concealed': {
1127
+ // Ratchet G2 (2026-07-17): marker adjective — hidden from normal
1128
+ // view until searching reveals it (IdentityTrait.concealed).
1129
+ const identity = entity.get(world_model_1.TraitType.IDENTITY);
1130
+ if (identity)
1131
+ identity.concealed = true;
1132
+ break;
1133
+ }
1134
+ case 'hiding-spot': {
1135
+ // Ratchet G3 (2026-07-17): bare = the actor may hide at any
1136
+ // position; `with position <word>` narrows to exactly one.
1137
+ const HIDING_POSITIONS = ['behind', 'under', 'on', 'inside'];
1138
+ const position = configValue(trait, 'position');
1139
+ if (position !== undefined && !HIDING_POSITIONS.includes(position)) {
1140
+ throw new errors_js_1.LoadError(`\`${position}\` is not a hiding position — use behind, under, on, or inside.`, trait.span);
1141
+ }
1142
+ entity.add(new world_model_1.ConcealmentTrait({
1143
+ positions: position
1144
+ ? [position]
1145
+ : [...HIDING_POSITIONS],
1146
+ quality: 'good',
1147
+ }));
760
1148
  break;
1149
+ }
761
1150
  case 'enterable': // ADR-218 §1a (ratchet F1) — default config, preposition `in`
762
1151
  if (!entity.has(world_model_1.TraitType.ENTERABLE))
763
1152
  entity.add(new world_model_1.EnterableTrait());
@@ -769,6 +1158,26 @@ class ChordStory {
769
1158
  case 'light-source':
770
1159
  entity.add(new world_model_1.LightSourceTrait());
771
1160
  break;
1161
+ case 'guard':
1162
+ case 'passive':
1163
+ case 'wanderer':
1164
+ case 'follower':
1165
+ case 'patrol': {
1166
+ // ADR-215 Q4: CORE NPC behavior vocabulary — no `use` gate.
1167
+ this.applyNpcAdjective(entity, irEntity, trait);
1168
+ break;
1169
+ }
1170
+ case 'combatant':
1171
+ case 'weapon': {
1172
+ // ADR-215 combat vocabulary — `use combat` extension adjectives.
1173
+ // The analyzer gated use-declaration and field names/types; this
1174
+ // check is the rogue-IR backstop.
1175
+ if (!(this.ir.uses ?? []).includes('combat')) {
1176
+ throw new errors_js_1.LoadError(`\`${trait.name}\` is \`combat\` extension vocabulary — add \`use combat\` to the story header.`, trait.span);
1177
+ }
1178
+ this.applyCombatAdjective(entity, irEntity, trait);
1179
+ break;
1180
+ }
772
1181
  case 'plural': {
773
1182
  const identity = entity.get(world_model_1.TraitType.IDENTITY);
774
1183
  if (identity)
@@ -777,7 +1186,7 @@ class ChordStory {
777
1186
  }
778
1187
  case 'dark': {
779
1188
  if (kind !== 'room') {
780
- throw new errors_1.LoadError(`\`dark\` applies to rooms only.`, trait.span);
1189
+ throw new errors_js_1.LoadError(`\`dark\` applies to rooms only.`, trait.span);
781
1190
  }
782
1191
  break; // unconditional dark handled by the room builder
783
1192
  }
@@ -787,13 +1196,247 @@ class ChordStory {
787
1196
  // via `set`, serialized with the world — AC-6-safe).
788
1197
  const def = this.ir.traits.find((t) => t.name === trait.name);
789
1198
  if (def) {
790
- entity.add(new ChordDataTrait(state_keys_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
1199
+ entity.add(new ChordDataTrait(state_keys_js_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
791
1200
  break;
792
1201
  }
793
- throw new errors_1.LoadError(`Trait \`${trait.name}\` is not declared (\`define trait ${trait.name}\`) and is not a v1 adjective.`, trait.span);
1202
+ throw new errors_js_1.LoadError(`Trait \`${trait.name}\` is not declared (\`define trait ${trait.name}\`) and is not a v1 adjective.`, trait.span);
1203
+ }
1204
+ }
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Per-entity NPC behavior configs stashed at composition time and
1209
+ * registered with the NPC service at engine-ready (world ids exist by
1210
+ * then, so patrol routes resolve). guard/passive use the plugin's
1211
+ * pre-registered behaviors; the factory-built three get per-entity ids.
1212
+ */
1213
+ npcBehaviors = [];
1214
+ /**
1215
+ * ADR-215 Q4 core NPC routing: compose NpcTrait from a behavior
1216
+ * adjective — behaviorId (`guard`/`passive` built-in; factory behaviors
1217
+ * get `chord.npc.<id>`), movement defaults (movement behaviors default
1218
+ * `canMove: true`), boolean fields via NPC_FIELD_ROUTES, and room-list
1219
+ * fields (`allowed-rooms`/`forbidden-rooms`) filled through the shared
1220
+ * pending-entity-ref mechanism once every entity exists.
1221
+ */
1222
+ applyNpcAdjective(entity, irEntity, trait) {
1223
+ const isFactory = trait.name === 'wanderer' || trait.name === 'follower' || trait.name === 'patrol';
1224
+ const data = {
1225
+ behaviorId: isFactory ? `chord.npc.${irEntity.id}` : trait.name,
1226
+ // Movement behaviors move by definition; `can-move false` overrides.
1227
+ canMove: isFactory,
1228
+ };
1229
+ for (const setting of trait.config) {
1230
+ const route = extension_registry_js_1.NPC_FIELD_ROUTES.get(setting.key);
1231
+ if (!route)
1232
+ continue; // behavior-factory params — consumed at engine-ready
1233
+ if (route.convert === 'boolean') {
1234
+ if (setting.value !== 'true' && setting.value !== 'false') {
1235
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`${setting.key}\` takes \`true\` or \`false\`, got \`${setting.value}\`.`, trait.span);
794
1236
  }
1237
+ data[route.field] = setting.value === 'true';
795
1238
  }
796
1239
  }
1240
+ const npcTrait = new world_model_1.NpcTrait(data);
1241
+ entity.add(npcTrait);
1242
+ // Room lists resolve after every entity exists (pass-1 ordering).
1243
+ for (const setting of trait.config) {
1244
+ const route = extension_registry_js_1.NPC_FIELD_ROUTES.get(setting.key);
1245
+ if (route?.convert !== 'rooms')
1246
+ continue;
1247
+ const target = [];
1248
+ npcTrait[route.field] = target;
1249
+ for (const memberIrId of setting.values ?? []) {
1250
+ this.pendingEntityRefs.push({
1251
+ irRefId: memberIrId,
1252
+ ownerName: irEntity.name,
1253
+ span: trait.span,
1254
+ apply: (worldId) => target.push(worldId),
1255
+ });
1256
+ }
1257
+ }
1258
+ if (isFactory) {
1259
+ this.npcBehaviors.push({ irId: irEntity.id, adjective: trait.name, config: trait.config, span: trait.span });
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Build one per-entity NPC behavior instance from its stashed config
1264
+ * (engine-ready time — world ids exist). Chord percentages convert to
1265
+ * the platform's fractions here (`move-chance 50` → 0.5).
1266
+ */
1267
+ buildNpcBehavior(pending) {
1268
+ const numberOf = (key) => {
1269
+ const setting = pending.config.find((s) => s.key === key);
1270
+ return setting ? Number(setting.value) : undefined;
1271
+ };
1272
+ const boolOf = (key) => {
1273
+ const setting = pending.config.find((s) => s.key === key);
1274
+ return setting ? setting.value === 'true' : undefined;
1275
+ };
1276
+ let behavior;
1277
+ switch (pending.adjective) {
1278
+ case 'wanderer': {
1279
+ const percent = numberOf('move-chance');
1280
+ behavior = (0, stdlib_2.createWandererBehavior)(percent === undefined ? {} : { moveChance: percent / 100 });
1281
+ break;
1282
+ }
1283
+ case 'follower':
1284
+ behavior = (0, stdlib_2.createFollowerBehavior)(boolOf('immediate') === undefined ? {} : { immediate: boolOf('immediate') });
1285
+ break;
1286
+ case 'patrol': {
1287
+ const routeSetting = pending.config.find((s) => s.key === 'route');
1288
+ if (!routeSetting || (routeSetting.values ?? []).length === 0) {
1289
+ throw new errors_js_1.LoadError(`A \`patrol\` NPC needs \`with route [ … ]\` naming its rooms.`, pending.span);
1290
+ }
1291
+ const route = (routeSetting.values ?? []).map((irId) => {
1292
+ const worldId = this.worldIds.get(irId);
1293
+ if (!worldId)
1294
+ throw new errors_js_1.LoadError(`A patrol route entry was never built.`, pending.span);
1295
+ return worldId;
1296
+ });
1297
+ behavior = (0, stdlib_2.createPatrolBehavior)({
1298
+ route,
1299
+ ...(boolOf('loop') === undefined ? {} : { loop: boolOf('loop') }),
1300
+ ...(numberOf('wait-turns') === undefined ? {} : { waitTurns: numberOf('wait-turns') }),
1301
+ });
1302
+ break;
1303
+ }
1304
+ default:
1305
+ throw new errors_js_1.LoadError(`Unknown NPC behavior adjective \`${pending.adjective}\`.`, pending.span);
1306
+ }
1307
+ behavior.id = `chord.npc.${pending.irId}`;
1308
+ return behavior;
1309
+ }
1310
+ /**
1311
+ * Lower one `define machine` onto the ADR-119 shapes: platform id
1312
+ * `chord.machine.<slug>`, role bindings as `$<role>` entries (world
1313
+ * ids), action triggers on `if.action.<gerund>`, Chord conditions as
1314
+ * custom guards over the shared evaluator, Chord bodies as one custom
1315
+ * effect each through the runtime's statement executor.
1316
+ */
1317
+ buildMachineDefinition(machine) {
1318
+ const bindings = {};
1319
+ for (const role of machine.roles) {
1320
+ const worldId = this.worldIds.get(role.entity);
1321
+ if (!worldId) {
1322
+ throw new errors_js_1.LoadError(`Machine \`${machine.name}\`: role \`${role.name}\`'s entity was never built.`, machine.span);
1323
+ }
1324
+ bindings[`$${role.name}`] = worldId;
1325
+ }
1326
+ const chordGuard = (condition) => ({
1327
+ type: 'custom',
1328
+ evaluate: (world) => this.evaluator.evalCondition(condition, { world: world }),
1329
+ });
1330
+ const chordEffect = (statements) => ({
1331
+ type: 'custom',
1332
+ execute: (world) => ({
1333
+ events: this.runtime
1334
+ .execMachineBody(statements, world)
1335
+ .map((e) => ({ type: e.type, data: e.data, entities: e.entities })),
1336
+ }),
1337
+ });
1338
+ const states = {};
1339
+ for (const state of machine.states) {
1340
+ const transitions = state.transitions.map((t) => {
1341
+ let trigger;
1342
+ switch (t.trigger.kind) {
1343
+ case 'action': {
1344
+ let targetEntity;
1345
+ if (t.trigger.target) {
1346
+ targetEntity = t.trigger.target.startsWith('$')
1347
+ ? t.trigger.target
1348
+ : this.worldIds.get(t.trigger.target);
1349
+ if (targetEntity === undefined) {
1350
+ throw new errors_js_1.LoadError(`Machine \`${machine.name}\`: a trigger target was never built.`, t.span);
1351
+ }
1352
+ }
1353
+ trigger = {
1354
+ type: 'action',
1355
+ actionId: `if.action.${t.trigger.action}`,
1356
+ ...(targetEntity !== undefined ? { targetEntity } : {}),
1357
+ };
1358
+ break;
1359
+ }
1360
+ case 'event':
1361
+ // ADR-256: translate the dotless Chord id to the platform runtime
1362
+ // type the machine fires on (media.* → dotted; author events pass
1363
+ // through), matching the emit seam.
1364
+ trigger = { type: 'event', eventId: (0, event_id_map_js_1.translateEventId)(t.trigger.event) };
1365
+ break;
1366
+ case 'condition':
1367
+ trigger = { type: 'condition', condition: chordGuard(t.trigger.condition) };
1368
+ break;
1369
+ }
1370
+ return {
1371
+ target: t.target,
1372
+ trigger,
1373
+ ...(t.condition ? { guard: chordGuard(t.condition) } : {}),
1374
+ };
1375
+ });
1376
+ states[state.name] = {
1377
+ ...(state.terminal ? { terminal: true } : {}),
1378
+ ...(state.onEnter.length > 0 ? { onEnter: [chordEffect(state.onEnter)] } : {}),
1379
+ ...(state.onExit.length > 0 ? { onExit: [chordEffect(state.onExit)] } : {}),
1380
+ ...(transitions.length > 0 ? { transitions } : {}),
1381
+ };
1382
+ }
1383
+ return {
1384
+ definition: {
1385
+ id: `chord.machine.${machine.name.replace(/\s+/g, '-')}`,
1386
+ initialState: machine.initialState,
1387
+ states,
1388
+ },
1389
+ bindings,
1390
+ };
1391
+ }
1392
+ /**
1393
+ * ADR-215 combat routing: compose `combatant` (CombatantTrait + the
1394
+ * REQUIRED HealthTrait per ADR-226 — health/max-health route THERE) or
1395
+ * `weapon` (WeaponTrait) from a composition's `with`-fields, via the
1396
+ * exported COMBAT_FIELD_ROUTES table the manifest-conformance test pins.
1397
+ */
1398
+ applyCombatAdjective(entity, irEntity, trait) {
1399
+ const values = {
1400
+ combatant: {},
1401
+ health: {},
1402
+ weapon: {},
1403
+ };
1404
+ for (const setting of trait.config) {
1405
+ const route = extension_registry_js_1.COMBAT_FIELD_ROUTES.get(setting.key);
1406
+ if (!route) {
1407
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`${setting.key}\` has no combat field route — the compiler manifest and loader routes are out of step.`, trait.span);
1408
+ }
1409
+ if (route.convert === 'number') {
1410
+ const parsed = Number(setting.value);
1411
+ if (Number.isNaN(parsed)) {
1412
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`${setting.key}\` needs a number, got \`${setting.value}\`.`, trait.span);
1413
+ }
1414
+ values[route.trait][route.field] = parsed;
1415
+ }
1416
+ else {
1417
+ if (setting.value !== 'true' && setting.value !== 'false') {
1418
+ throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`${setting.key}\` takes \`true\` or \`false\`, got \`${setting.value}\`.`, trait.span);
1419
+ }
1420
+ values[route.trait][route.field] = setting.value === 'true';
1421
+ }
1422
+ }
1423
+ if (trait.name === 'weapon') {
1424
+ entity.add(new world_model_1.WeaponTrait(values.weapon));
1425
+ return;
1426
+ }
1427
+ // CombatantTrait REQUIRES a HealthTrait (ADR-226 §2) — auto-attach,
1428
+ // seeded from the health/max-health fields (defaults when omitted).
1429
+ if (!entity.has(world_model_1.TraitType.HEALTH)) {
1430
+ entity.add(new world_model_1.HealthTrait(values.health));
1431
+ }
1432
+ else if (Object.keys(values.health).length > 0) {
1433
+ const health = entity.get(world_model_1.TraitType.HEALTH);
1434
+ Object.assign(health, values.health);
1435
+ if (values.health.health !== undefined && values.health.maxHealth === undefined) {
1436
+ health.maxHealth = Math.max(health.maxHealth, health.health);
1437
+ }
1438
+ }
1439
+ entity.add(new world_model_1.CombatantTrait(values.combatant));
797
1440
  }
798
1441
  /**
799
1442
  * Initial values for a `define trait` instance: declared `starts`
@@ -811,7 +1454,7 @@ class ChordStory {
811
1454
  const lower = setting.value.toLowerCase();
812
1455
  const target = this.ir.entities.find((e) => e.name.toLowerCase() === lower || e.aka.includes(lower));
813
1456
  if (!target) {
814
- throw new errors_1.LoadError(`\`${setting.value}\` (config \`${setting.key}\`) names no entity.`, comp.span);
1457
+ throw new errors_js_1.LoadError(`\`${setting.value}\` (config \`${setting.key}\`) names no entity.`, comp.span);
815
1458
  }
816
1459
  values[setting.key] = target.id;
817
1460
  }
@@ -838,7 +1481,7 @@ class ChordStory {
838
1481
  requireWorldId(irId, at) {
839
1482
  const id = this.worldIds.get(irId);
840
1483
  if (!id) {
841
- throw new errors_1.LoadError(`Entity \`${irId}\` is referenced before it exists in the world.`, at?.span);
1484
+ throw new errors_js_1.LoadError(`Entity \`${irId}\` is referenced before it exists in the world.`, at?.span);
842
1485
  }
843
1486
  return id;
844
1487
  }
@@ -890,9 +1533,9 @@ class ChordStory {
890
1533
  if (phrase.verbatim) {
891
1534
  // The analyzer already errors here (analysis.verbatim-marker);
892
1535
  // this is the loader's defensive half of the same contract.
893
- throw new errors_1.LoadError(`\`{${marker}}\` in \`${irEntity.name}\` names a verbatim phrase — verbatim text cannot splice at a description marker.`, phrase.span);
1536
+ throw new errors_js_1.LoadError(`\`{${marker}}\` in \`${irEntity.name}\` names a verbatim phrase — verbatim text cannot splice at a description marker.`, phrase.span);
894
1537
  }
895
- const variantTexts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_1.withLineBreaks)(v.text)));
1538
+ const variantTexts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_js_1.withLineBreaks)(v.text)));
896
1539
  let mentions;
897
1540
  if (phrase.condition) {
898
1541
  const subject = presenceSubject(phrase.condition, irEntity.id);
@@ -904,7 +1547,7 @@ class ChordStory {
904
1547
  gates.push(() => (0, stdlib_1.registerSnippetGate)(entity.id, marker, () => this.evaluator.evalCondition(condition, { world })));
905
1548
  }
906
1549
  }
907
- const selector = phrase.strategy ? runtime_1.STRATEGY_SELECTOR[phrase.strategy] : undefined;
1550
+ const selector = phrase.strategy ? runtime_js_1.STRATEGY_SELECTOR[phrase.strategy] : undefined;
908
1551
  let entry;
909
1552
  if (!selector && variantTexts.length === 1) {
910
1553
  // Single-variant plain phrase → plain string entry, never a Choice.
@@ -999,7 +1642,7 @@ class ChordStory {
999
1642
  break;
1000
1643
  if (!phrase.condition)
1001
1644
  continue; // analyzer already errored (detail-unconditional)
1002
- const text = (0, text_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
1645
+ const text = (0, text_js_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
1003
1646
  const shape = detailTraitShape(phrase.condition, irEntity.id);
1004
1647
  if (shape === 'on' && entity.has(world_model_1.TraitType.SWITCHABLE)) {
1005
1648
  entity.get(world_model_1.TraitType.SWITCHABLE).detailWhenOn = text;
@@ -1033,9 +1676,31 @@ class ChordStory {
1033
1676
  phraseText(key) {
1034
1677
  const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
1035
1678
  if (!phrase) {
1036
- throw new errors_1.LoadError(`Phrase \`${key}\` is missing from the IR — the compiler gate should have caught this.`);
1679
+ throw new errors_js_1.LoadError(`Phrase \`${key}\` is missing from the IR — the compiler gate should have caught this.`);
1680
+ }
1681
+ return (0, text_js_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
1682
+ }
1683
+ /**
1684
+ * Evaluate a channel's `return` construct (ADR-253 D1) against an event's
1685
+ * payload to produce the channel's value:
1686
+ * - `field` → the raw field value (may be a non-string);
1687
+ * - `text` → the template with each `(slot)` replaced by its event field;
1688
+ * - `phrase` → the named phrase's first-variant text, its `(slot)`s likewise
1689
+ * filled from the event payload (locale-aware via `phraseText`).
1690
+ */
1691
+ evaluateChannelReturn(returns, data) {
1692
+ const fill = (template) => template.replace(/\(\s*([^)]+?)\s*\)/g, (_m, name) => {
1693
+ const v = data[name];
1694
+ return v === undefined || v === null ? '' : String(v);
1695
+ });
1696
+ switch (returns.kind) {
1697
+ case 'field':
1698
+ return data[returns.field];
1699
+ case 'text':
1700
+ return fill(returns.text);
1701
+ case 'phrase':
1702
+ return fill(this.phraseText(returns.phrase));
1037
1703
  }
1038
- return (0, text_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
1039
1704
  }
1040
1705
  }
1041
1706
  exports.ChordStory = ChordStory;
@@ -1080,11 +1745,26 @@ function presenceSubject(cond, roomIrId) {
1080
1745
  }
1081
1746
  return null;
1082
1747
  }
1748
+ /**
1749
+ * ADR-231 D5a platform mapping: `starts <state>` word → the paired trait's
1750
+ * initial-value field and the boolean it sets. The language-side pairing
1751
+ * table (which trait must be composed) is @sharpee/chord's
1752
+ * STARTS_STATE_PAIRINGS; this is the loader's platform half — future
1753
+ * stateful traits extend both tables, not the code.
1754
+ */
1755
+ const STARTS_STATE_TRAIT_FIELDS = new Map([
1756
+ ['locked', { traitType: world_model_1.TraitType.LOCKABLE, traitName: 'lockable', field: 'isLocked', value: true }],
1757
+ ['unlocked', { traitType: world_model_1.TraitType.LOCKABLE, traitName: 'lockable', field: 'isLocked', value: false }],
1758
+ ['closed', { traitType: world_model_1.TraitType.OPENABLE, traitName: 'openable', field: 'isOpen', value: false }],
1759
+ ['open', { traitType: world_model_1.TraitType.OPENABLE, traitName: 'openable', field: 'isOpen', value: true }],
1760
+ ['off', { traitType: world_model_1.TraitType.SWITCHABLE, traitName: 'switchable', field: 'isOn', value: false }],
1761
+ ['on', { traitType: world_model_1.TraitType.SWITCHABLE, traitName: 'switchable', field: 'isOn', value: true }],
1762
+ ]);
1083
1763
  /** Chord direction word → world-model DirectionType. */
1084
1764
  function toDirection(word, at) {
1085
1765
  const dir = world_model_1.Direction[word.toUpperCase()];
1086
1766
  if (!dir)
1087
- throw new errors_1.LoadError(`Unknown direction \`${word}\`.`, at?.span);
1767
+ throw new errors_js_1.LoadError(`Unknown direction \`${word}\`.`, at?.span);
1088
1768
  return dir;
1089
1769
  }
1090
1770
  /** `with capacity N` on a supporter kind. */
@@ -1095,6 +1775,14 @@ function supporterCapacity(kind) {
1095
1775
  function configValue(comp, key) {
1096
1776
  return comp.config.find((c) => c.key === key)?.value;
1097
1777
  }
1778
+ /**
1779
+ * Ratchet R3 (ADR-234 D6): the adjective's single-entity `with` value —
1780
+ * keyless (`lockable with the iron key`). The parser stores it under the
1781
+ * empty key with valueKind 'name'.
1782
+ */
1783
+ function entityConfigValue(comp) {
1784
+ return comp.config.find((c) => c.key === '' && c.valueKind === 'name')?.value;
1785
+ }
1098
1786
  /**
1099
1787
  * The Language Provider template for an IR phrase. Single-variant phrases
1100
1788
  * register their text (with `{br}` mapped to hard line breaks); verbatim
@@ -1105,7 +1793,7 @@ function templateFor(phrase) {
1105
1793
  if (phrase.verbatim)
1106
1794
  return '{verbatim:text}';
1107
1795
  if (phrase.strategy === null && phrase.variants.length === 1)
1108
- return (0, text_1.withLineBreaks)(phrase.variants[0].text);
1796
+ return (0, text_js_1.withLineBreaks)(phrase.variants[0].text);
1109
1797
  return '{variants}';
1110
1798
  }
1111
1799
  /**
@@ -1122,7 +1810,7 @@ function toVocabularyVerb(verb) {
1122
1810
  const KNOWN = { 'put on': 'PUT_ON' };
1123
1811
  const actionId = KNOWN[`${base} ${preposition ?? ''}`.trim()];
1124
1812
  if (!actionId || slots !== 2) {
1125
- throw new errors_1.LoadError(`\`define verb ${verb.verbs.join(' or ')}\` maps to \`${words.join(' ')}\`, which the Phase A loader cannot register.`);
1813
+ throw new errors_js_1.LoadError(`\`define verb ${verb.verbs.join(' or ')}\` maps to \`${words.join(' ')}\`, which the Phase A loader cannot register.`);
1126
1814
  }
1127
1815
  return {
1128
1816
  actionId,