@sharpee/story-loader 3.1.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/chain-map.d.ts +40 -0
- package/chain-map.d.ts.map +1 -0
- package/chain-map.js +39 -0
- package/chain-map.js.map +1 -0
- package/evaluator.d.ts +16 -0
- package/evaluator.d.ts.map +1 -1
- package/evaluator.js +56 -21
- package/evaluator.js.map +1 -1
- package/event-contract.d.ts +18 -0
- package/event-contract.d.ts.map +1 -1
- package/event-contract.js +28 -1
- package/event-contract.js.map +1 -1
- package/event-id-map.d.ts +42 -0
- package/event-id-map.d.ts.map +1 -0
- package/event-id-map.js +62 -0
- package/event-id-map.js.map +1 -0
- package/extension-registry.d.ts +57 -0
- package/extension-registry.d.ts.map +1 -0
- package/extension-registry.js +90 -0
- package/extension-registry.js.map +1 -0
- package/index.d.ts +9 -7
- package/index.d.ts.map +1 -1
- package/index.js +28 -25
- package/index.js.map +1 -1
- package/loader.d.ts +122 -6
- package/loader.d.ts.map +1 -1
- package/loader.js +967 -119
- package/loader.js.map +1 -1
- package/message-alias-map.d.ts +25 -0
- package/message-alias-map.d.ts.map +1 -0
- package/message-alias-map.js +830 -0
- package/message-alias-map.js.map +1 -0
- package/package.json +13 -9
- package/phrasebook-data.d.ts +27 -0
- package/phrasebook-data.d.ts.map +1 -0
- package/phrasebook-data.js +6 -0
- package/phrasebook-data.js.map +1 -0
- package/runtime.d.ts +139 -19
- package/runtime.d.ts.map +1 -1
- package/runtime.js +692 -166
- package/runtime.js.map +1 -1
package/loader.js
CHANGED
|
@@ -26,16 +26,23 @@ exports.createStory = createStory;
|
|
|
26
26
|
*/
|
|
27
27
|
const chord_1 = require("@sharpee/chord");
|
|
28
28
|
const stdlib_1 = require("@sharpee/stdlib");
|
|
29
|
+
const ext_hunger_1 = require("@sharpee/ext-hunger");
|
|
29
30
|
const if_domain_1 = require("@sharpee/if-domain");
|
|
30
|
-
const
|
|
31
|
+
const plugins_1 = require("@sharpee/plugins");
|
|
32
|
+
const plugin_npc_1 = require("@sharpee/plugin-npc");
|
|
31
33
|
const plugin_scheduler_1 = require("@sharpee/plugin-scheduler");
|
|
34
|
+
const plugin_state_machine_1 = require("@sharpee/plugin-state-machine");
|
|
35
|
+
const stdlib_2 = require("@sharpee/stdlib");
|
|
32
36
|
const world_model_1 = require("@sharpee/world-model");
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
37
|
+
const chain_map_js_1 = require("./chain-map.js");
|
|
38
|
+
const errors_js_1 = require("./errors.js");
|
|
39
|
+
const event_id_map_js_1 = require("./event-id-map.js");
|
|
40
|
+
const extension_registry_js_1 = require("./extension-registry.js");
|
|
41
|
+
const evaluator_js_1 = require("./evaluator.js");
|
|
42
|
+
const hatch_context_js_1 = require("./hatch-context.js");
|
|
43
|
+
const runtime_js_1 = require("./runtime.js");
|
|
44
|
+
const state_keys_js_1 = require("./state-keys.js");
|
|
45
|
+
const text_js_1 = require("./text.js");
|
|
39
46
|
/**
|
|
40
47
|
* Marker trait for entities carrying loader-compiled `detail` providers
|
|
41
48
|
* (Z3b): the one state-clause contributor registered per load looks up the
|
|
@@ -76,8 +83,8 @@ class ChordStory {
|
|
|
76
83
|
producers = new Map();
|
|
77
84
|
/** Bound `define action X from` hatches: four-phase Action objects by name. */
|
|
78
85
|
boundActions = new Map();
|
|
79
|
-
/** Bound `define
|
|
80
|
-
|
|
86
|
+
/** Bound `define chain X from` hatches (ADR-094): EventChainHandlers by chain alias. */
|
|
87
|
+
boundChains = new Map();
|
|
81
88
|
/** The turn-by-turn runtime (rules, on-clauses, derived properties). */
|
|
82
89
|
runtime;
|
|
83
90
|
/** The condition evaluator — shared with the runtime; Z2 gate thunks close over it. */
|
|
@@ -98,7 +105,7 @@ class ChordStory {
|
|
|
98
105
|
const lower = name.toLowerCase();
|
|
99
106
|
const target = this.ir.entities.find((e) => e.name.toLowerCase() === lower || e.aka.includes(lower));
|
|
100
107
|
if (!target) {
|
|
101
|
-
throw new
|
|
108
|
+
throw new errors_js_1.LoadError(`\`${name}\` (config \`${configKey}\`) names no entity.`, span);
|
|
102
109
|
}
|
|
103
110
|
return { irRefId: target.id, ownerName: owner.name, span, apply };
|
|
104
111
|
}
|
|
@@ -120,7 +127,7 @@ class ChordStory {
|
|
|
120
127
|
constructor(ir, options) {
|
|
121
128
|
this.ir = ir;
|
|
122
129
|
if (ir.format !== chord_1.IR_FORMAT) {
|
|
123
|
-
throw new
|
|
130
|
+
throw new errors_js_1.LoadError(`Unsupported IR format \`${String(ir.format)}\` — this loader reads \`${chord_1.IR_FORMAT}\`.`);
|
|
124
131
|
}
|
|
125
132
|
this.config = {
|
|
126
133
|
id: ir.meta.fields.id ?? ir.meta.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
@@ -130,8 +137,8 @@ class ChordStory {
|
|
|
130
137
|
description: ir.meta.fields.blurb,
|
|
131
138
|
};
|
|
132
139
|
this.bindHatches(options);
|
|
133
|
-
this.evaluator = new
|
|
134
|
-
this.runtime = new
|
|
140
|
+
this.evaluator = new evaluator_js_1.Evaluator(ir, this, options.seed);
|
|
141
|
+
this.runtime = new runtime_js_1.ChordRuntime(ir, this, this.evaluator);
|
|
135
142
|
}
|
|
136
143
|
/** The world entity ID for an IR entity ID (after initializeWorld). */
|
|
137
144
|
entityId(irId) {
|
|
@@ -151,29 +158,31 @@ class ChordStory {
|
|
|
151
158
|
// any module — no author-supplied code is read, called, or bound.
|
|
152
159
|
if ((options.profile ?? 'devkit') === 'pure-ir' && this.ir.hatches.length > 0) {
|
|
153
160
|
const names = this.ir.hatches.map((h) => `\`${h.name}\` (${h.modulePath})`).join(', ');
|
|
154
|
-
throw new
|
|
161
|
+
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
162
|
}
|
|
156
163
|
for (const hatch of this.ir.hatches) {
|
|
157
164
|
const module = options.hatchModules?.[hatch.modulePath];
|
|
158
165
|
if (!module) {
|
|
159
|
-
throw new
|
|
166
|
+
throw new errors_js_1.LoadError(`Hatch module \`${hatch.modulePath}\` was not provided to the loader.`, hatch.span);
|
|
160
167
|
}
|
|
161
|
-
|
|
168
|
+
// A chain hatch's alias (`opened-revealed`) is not a JS identifier, so its
|
|
169
|
+
// module default-exports the handler (falling back to a matching named export).
|
|
170
|
+
const bound = hatch.hatchKind === 'chain' ? (module[hatch.name] ?? module.default) : module[hatch.name];
|
|
162
171
|
// Bind-time `'chord.'` lint (design.md §5.6, best-effort backstop —
|
|
163
172
|
// the staging facade is the wall): the loader-private state namespace
|
|
164
173
|
// is off-limits to hatches; a quoted literal fails the bind atomically,
|
|
165
174
|
// like a missing export. The devkit source lint is the authoritative
|
|
166
175
|
// layer (this one can miss minified code and can trip on a quoted
|
|
167
176
|
// literal inside a compiled-in comment — reword the comment).
|
|
168
|
-
const chordLiteral = (0,
|
|
177
|
+
const chordLiteral = (0, hatch_context_js_1.findChordLiteral)(bound);
|
|
169
178
|
if (chordLiteral !== null) {
|
|
170
|
-
throw new
|
|
179
|
+
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
180
|
}
|
|
172
181
|
const kind = hatch.hatchKind ?? 'text';
|
|
173
182
|
switch (kind) {
|
|
174
183
|
case 'text': {
|
|
175
184
|
if (typeof bound !== 'function') {
|
|
176
|
-
throw new
|
|
185
|
+
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
186
|
}
|
|
178
187
|
this.producers.set(hatch.name, bound);
|
|
179
188
|
break;
|
|
@@ -182,48 +191,130 @@ class ChordStory {
|
|
|
182
191
|
// Interface Contract 3: the export IS a four-phase Action.
|
|
183
192
|
const action = bound;
|
|
184
193
|
if (!action || typeof action !== 'object' || typeof action.validate !== 'function' || typeof action.execute !== 'function') {
|
|
185
|
-
throw new
|
|
194
|
+
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
195
|
}
|
|
187
196
|
this.boundActions.set(hatch.name, bound);
|
|
188
197
|
break;
|
|
189
198
|
}
|
|
190
|
-
case '
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
199
|
+
case 'chain': {
|
|
200
|
+
// ADR-094: the export IS an EventChainHandler; registered in
|
|
201
|
+
// initializeWorld to REPLACE the stdlib chain (same key).
|
|
202
|
+
if (typeof bound !== 'function') {
|
|
203
|
+
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
204
|
}
|
|
195
|
-
this.
|
|
205
|
+
this.boundChains.set(hatch.name, bound);
|
|
196
206
|
break;
|
|
197
207
|
}
|
|
208
|
+
// The `behavior` hatch kind was removed (ADR-235 D2, 2026-07-18) —
|
|
209
|
+
// it carried no binding key and could never fire; the compiler now
|
|
210
|
+
// refuses the declaration outright.
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
213
|
}
|
|
201
214
|
// ------------------------------------------------------------ lifecycle
|
|
202
215
|
initializeWorld(world) {
|
|
203
216
|
this.world = world;
|
|
217
|
+
// ADR-094 chain hatches: register each replacement handler under its stdlib
|
|
218
|
+
// chain key. `registerStandardChains` ran at engine init (before setStory →
|
|
219
|
+
// initializeWorld), so a same-key `chainEvent` REPLACES the stdlib default
|
|
220
|
+
// in place. Idempotent across restart (keyed replacement).
|
|
221
|
+
for (const [alias, handler] of this.boundChains) {
|
|
222
|
+
const reg = (0, chain_map_js_1.resolveChain)(alias);
|
|
223
|
+
if (!reg) {
|
|
224
|
+
// The chord analyzer's `analysis.unknown-chain` gate catches this first;
|
|
225
|
+
// this backstops rogue IR reaching the loader.
|
|
226
|
+
throw new errors_js_1.LoadError(`Chain hatch \`${alias}\` names no known stdlib chain.`);
|
|
227
|
+
}
|
|
228
|
+
world.chainEvent(reg.trigger, handler, { key: reg.key, priority: reg.priority });
|
|
229
|
+
}
|
|
230
|
+
// ADR-215: `use`-declared trusted extensions register FIRST — their
|
|
231
|
+
// world-side registrations (interceptors, resolvers) must exist before
|
|
232
|
+
// any entity composes their vocabulary. Unknown names are LoadErrors
|
|
233
|
+
// (the compiler's manifest gate catches them first; this backstops
|
|
234
|
+
// rogue IR).
|
|
235
|
+
for (const name of this.ir.uses ?? []) {
|
|
236
|
+
const registration = extension_registry_js_1.EXTENSION_REGISTRY.get(name);
|
|
237
|
+
if (!registration) {
|
|
238
|
+
throw new errors_js_1.LoadError(`\`use ${name}\` names no trusted extension — known: ${[...extension_registry_js_1.EXTENSION_REGISTRY.keys()].join(', ')}.`);
|
|
239
|
+
}
|
|
240
|
+
registration.registerWorld?.(world);
|
|
241
|
+
}
|
|
204
242
|
const built = [];
|
|
205
|
-
// Pass
|
|
243
|
+
// Pass 0 — regions, parents before children (ADR-236 D3): a nested
|
|
244
|
+
// region's `parentRegionId` is validated by `createRegion` at creation,
|
|
245
|
+
// so the parent's world entity must already exist.
|
|
246
|
+
for (const irEntity of this.regionsInParentFirstOrder()) {
|
|
247
|
+
built.push({ ir: irEntity, entity: this.buildEntity(world, irEntity) });
|
|
248
|
+
}
|
|
249
|
+
// Pass 1 — create every remaining non-player entity.
|
|
206
250
|
for (const irEntity of this.ir.entities) {
|
|
207
|
-
if (irEntity.isPlayer)
|
|
251
|
+
if (irEntity.isPlayer || this.worldIds.has(irEntity.id))
|
|
208
252
|
continue;
|
|
209
253
|
built.push({ ir: irEntity, entity: this.buildEntity(world, irEntity) });
|
|
210
254
|
}
|
|
211
255
|
// Pass 2 — placement, exits, blocked exits, initial states.
|
|
256
|
+
// Placement is WORLD CONSTRUCTION, not a runtime action: it goes through
|
|
257
|
+
// AuthorModel.moveEntity, which bypasses the runtime containment rules
|
|
258
|
+
// (a closed trunk can hold its contents at load — the plain
|
|
259
|
+
// world.moveEntity refusal was a silent drop; David-approved fix,
|
|
260
|
+
// 2026-07-18, matching the TS story path's established pattern).
|
|
261
|
+
const author = new world_model_1.AuthorModel(world.getDataStore(), world);
|
|
212
262
|
for (const { ir: irEntity, entity } of built) {
|
|
213
263
|
if (irEntity.placement && irEntity.placement.relation !== 'starts-in') {
|
|
214
|
-
|
|
264
|
+
author.moveEntity(entity.id, this.requireWorldId(irEntity.placement.place, irEntity));
|
|
215
265
|
}
|
|
216
|
-
|
|
217
|
-
|
|
266
|
+
// ADR-236 D2: region membership through the platform seam —
|
|
267
|
+
// `assignRoom` sets RoomTrait.regionId (never touched directly here);
|
|
268
|
+
// member regions were parented at creation (pass 0), so only room
|
|
269
|
+
// members remain to wire.
|
|
270
|
+
for (const member of irEntity.containing ?? []) {
|
|
271
|
+
const memberIr = this.ir.entities.find((e) => e.id === member.id);
|
|
272
|
+
if (memberIr && memberIr.kinds.some((k) => k.name === 'room')) {
|
|
273
|
+
world.assignRoom(this.requireWorldId(member.id, irEntity), entity.id);
|
|
274
|
+
}
|
|
218
275
|
}
|
|
219
|
-
for (const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
276
|
+
for (const exit of irEntity.exits) {
|
|
277
|
+
const toId = this.requireWorldId(exit.to, irEntity);
|
|
278
|
+
const direction = toDirection(exit.direction, irEntity);
|
|
279
|
+
if (exit.via === null) {
|
|
280
|
+
// Defensive (Phase 8 #6, belt-and-suspenders with the analyzer's
|
|
281
|
+
// door-plain-mirror gate): connectRooms stamps BOTH directions, so
|
|
282
|
+
// a plain exit whose reverse side is already door-wired would
|
|
283
|
+
// silently unwire that door. The compiler refuses this; reaching
|
|
284
|
+
// here means rogue IR.
|
|
285
|
+
const targetRoomTrait = world.getEntity(toId)?.get(world_model_1.TraitType.ROOM);
|
|
286
|
+
const reverseExit = targetRoomTrait?.exits?.[(0, world_model_1.getOppositeDirection)(direction)];
|
|
287
|
+
if (reverseExit?.via && reverseExit.destination === entity.id) {
|
|
288
|
+
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);
|
|
289
|
+
}
|
|
290
|
+
world.connectRooms(entity.id, toId, direction);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
// ADR-234 D1/D2 via ADR-237 D4: the door exit wires through the
|
|
294
|
+
// one platform primitive — DoorTrait attached here (room1 = the
|
|
295
|
+
// declaring room, per the createDoor placement convention), then
|
|
296
|
+
// connectRooms stamps `via` both directions and places the door.
|
|
297
|
+
// A door wires exactly once: the analyzer's checkDoors gates
|
|
298
|
+
// guarantee any second reference is the exact mirror, whose exits
|
|
299
|
+
// the first wiring already stamped — verified, then skipped.
|
|
300
|
+
const doorId = this.requireWorldId(exit.via, irEntity);
|
|
301
|
+
const door = world.getEntity(doorId);
|
|
302
|
+
const doorTrait = door?.get(world_model_1.TraitType.DOOR);
|
|
303
|
+
if (doorTrait) {
|
|
304
|
+
const isMirror = doorTrait.room1 === toId && doorTrait.room2 === entity.id;
|
|
305
|
+
if (!isMirror) {
|
|
306
|
+
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);
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
225
309
|
}
|
|
310
|
+
door?.add(new world_model_1.DoorTrait({ room1: entity.id, room2: toId }));
|
|
311
|
+
world.connectRooms(entity.id, toId, direction, doorId);
|
|
226
312
|
}
|
|
313
|
+
// Blocked exits — ADR-240 D2 (Option A): ALL of them, conditional and
|
|
314
|
+
// unconditional alike, are registered as live evaluators by the
|
|
315
|
+
// runtime's bind(); nothing is stamped onto RoomTrait.blockedExits
|
|
316
|
+
// here anymore (the trait map remains the hand-written TS stories'
|
|
317
|
+
// surface, consulted by going as the fall-through).
|
|
227
318
|
// ADR-227: `deadly: <phrase>` — the no-escape room marker lowers to
|
|
228
319
|
// DeadlyRoomTrait (safeVerbs default look/examine); the ENGINE
|
|
229
320
|
// auto-registers the deadly-room transformer, so no runtime code here.
|
|
@@ -238,7 +329,7 @@ class ChordStory {
|
|
|
238
329
|
// to one command transformer in onEngineReady.
|
|
239
330
|
for (const deadly of irEntity.deadlyExits) {
|
|
240
331
|
if (deadly.condition !== null) {
|
|
241
|
-
throw new
|
|
332
|
+
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
333
|
}
|
|
243
334
|
const direction = toDirection(deadly.direction, irEntity);
|
|
244
335
|
const byRoom = this.deadlyExits.get(entity.id) ?? new Map();
|
|
@@ -246,7 +337,7 @@ class ChordStory {
|
|
|
246
337
|
this.deadlyExits.set(entity.id, byRoom);
|
|
247
338
|
}
|
|
248
339
|
if (irEntity.states.length > 0) {
|
|
249
|
-
world.setStateValue(
|
|
340
|
+
world.setStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irEntity.id, irEntity.states[0]);
|
|
250
341
|
}
|
|
251
342
|
// Z2 (ADR-211): compile `{key}` description markers onto ADR-209
|
|
252
343
|
// snippet storage — atomically per room, before the engine's
|
|
@@ -255,15 +346,45 @@ class ChordStory {
|
|
|
255
346
|
this.compileRoomSnippets(world, irEntity, entity);
|
|
256
347
|
}
|
|
257
348
|
}
|
|
349
|
+
// ADR-234 D3 backstop (rogue IR — the compiler's `door-unconnected`
|
|
350
|
+
// gate refuses this): a door no `through` exit wired has no room pair
|
|
351
|
+
// and could never resolve in play.
|
|
352
|
+
for (const { ir: irEntity, entity } of built) {
|
|
353
|
+
if (irEntity.kinds.some((k) => k.name === 'door') && !entity.has(world_model_1.TraitType.DOOR)) {
|
|
354
|
+
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);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
258
357
|
// The story object starts in its first declared phase (ratchet D2).
|
|
259
358
|
if (this.ir.story.states.length > 0) {
|
|
260
|
-
world.setStateValue(
|
|
359
|
+
world.setStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY, this.ir.story.states[0]);
|
|
261
360
|
}
|
|
262
361
|
// Declared scores set the ceiling (dedup-by-identity makes the sum
|
|
263
362
|
// exact — ADR-129).
|
|
264
363
|
if (this.ir.scores.length > 0) {
|
|
265
364
|
world.setMaxScore(this.ir.scores.reduce((sum, s) => sum + s.worth, 0));
|
|
266
365
|
}
|
|
366
|
+
// ADR-261 D4's rogue-IR backstop — the same two-layer shape
|
|
367
|
+
// `define machine` uses. The compiler's `analysis.scoring-needs-use`
|
|
368
|
+
// catches this first; hand-built IR reaches here, and a gated construct
|
|
369
|
+
// must never be silently dead.
|
|
370
|
+
if ((this.ir.scores.length > 0 || this.ir.ranks.length > 0)
|
|
371
|
+
&& !(this.ir.uses ?? []).includes('scoring')) {
|
|
372
|
+
throw new errors_js_1.LoadError('`score`/`award`/`ranks` need `use scoring` in the story header.', (this.ir.ranks[0] ?? this.ir.scores[0]).span);
|
|
373
|
+
}
|
|
374
|
+
// The rank ladder lowers onto ADR-260 D2's seam, beside the ceiling it
|
|
375
|
+
// is independent of (thresholds are absolute points, so the two never
|
|
376
|
+
// interact). Generic and name-agnostic: the loader lowers `ir.ranks` the
|
|
377
|
+
// way it lowers any other IR field and never learns that `scoring` is the
|
|
378
|
+
// extension consuming it — which is what keeps ADR-260 D5's
|
|
379
|
+
// no-special-casing rule intact. `phraseKey` stays behind, in the story
|
|
380
|
+
// layer, where the promotion reaction reads it (ADR-261 D7).
|
|
381
|
+
if (this.ir.ranks.length > 0) {
|
|
382
|
+
world.setRanks(this.ir.ranks.map((r) => ({
|
|
383
|
+
id: r.id,
|
|
384
|
+
name: r.name,
|
|
385
|
+
threshold: r.threshold,
|
|
386
|
+
})));
|
|
387
|
+
}
|
|
267
388
|
// Z3 (ADR-213): one pre-removal observer serves every authored
|
|
268
389
|
// `disappeared` block — witnessed-only, enqueued for the report pass.
|
|
269
390
|
this.registerRemovalObserver(world);
|
|
@@ -329,6 +450,17 @@ class ChordStory {
|
|
|
329
450
|
if (startIr) {
|
|
330
451
|
world.moveEntity(player.id, this.requireWorldId(startIr, irPlayer ?? undefined));
|
|
331
452
|
}
|
|
453
|
+
// Player-block composition (Gap-2 ruling, David 2026-07-18): the
|
|
454
|
+
// player composes trait adjectives + `starts` states through the SAME
|
|
455
|
+
// loader path as any entity (`a person` is analyzer-gated to a no-op;
|
|
456
|
+
// NPC behaviors are refused at compile). Runs here — the second
|
|
457
|
+
// lifecycle hook — so entity-valued configs resolve against the
|
|
458
|
+
// fully-built world regardless of createPlayer/initializeWorld order.
|
|
459
|
+
if (irPlayer) {
|
|
460
|
+
this.applyTraitAdjectives(player, irPlayer, null);
|
|
461
|
+
this.applyStartsStates(player, irPlayer);
|
|
462
|
+
this.resolvePendingEntityRefs();
|
|
463
|
+
}
|
|
332
464
|
// Carried items: into inventory (ADR-230 Phase 6 — `carries the X`).
|
|
333
465
|
for (const carriedIrId of irPlayer?.carries ?? []) {
|
|
334
466
|
world.moveEntity(this.requireWorldId(carriedIrId, irPlayer ?? undefined), player.id);
|
|
@@ -340,14 +472,13 @@ class ChordStory {
|
|
|
340
472
|
const worn = world.getEntity(wornId);
|
|
341
473
|
const wearable = worn?.get(world_model_1.TraitType.WEARABLE);
|
|
342
474
|
if (!wearable) {
|
|
343
|
-
throw new
|
|
475
|
+
throw new errors_js_1.LoadError(`\`${wornIrId}\` is worn by the player but is not wearable.`, irPlayer?.span);
|
|
344
476
|
}
|
|
345
477
|
wearable.worn = true;
|
|
346
478
|
wearable.wornBy = player.id;
|
|
347
479
|
}
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
this.runtime.recomputeDerived(world);
|
|
480
|
+
// ADR-240: no initial derived-property evaluation — derived state is
|
|
481
|
+
// registered evaluators, consulted live at every read.
|
|
351
482
|
}
|
|
352
483
|
extendLanguage(language) {
|
|
353
484
|
// `addMessage` is the concrete providers' registration surface
|
|
@@ -355,12 +486,23 @@ class ChordStory {
|
|
|
355
486
|
// structurally so the loader stays locale-neutral.
|
|
356
487
|
const registry = language;
|
|
357
488
|
if (typeof registry.addMessage !== 'function') {
|
|
358
|
-
throw new
|
|
489
|
+
throw new errors_js_1.LoadError('The language provider does not support message registration (addMessage).');
|
|
359
490
|
}
|
|
360
491
|
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
361
492
|
for (const [key, phrase] of Object.entries(table)) {
|
|
362
493
|
registry.addMessage(key, templateFor(phrase));
|
|
363
494
|
}
|
|
495
|
+
// ADR-242 D7: declared pronoun sets ride the same seam — the same
|
|
496
|
+
// structural probe, throwing a legible error only when a story
|
|
497
|
+
// actually declares sets the provider cannot take.
|
|
498
|
+
if (this.ir.pronounSets.length > 0) {
|
|
499
|
+
if (typeof registry.registerPronounSet !== 'function') {
|
|
500
|
+
throw new errors_js_1.LoadError('The language provider does not support pronoun-set registration (registerPronounSet).');
|
|
501
|
+
}
|
|
502
|
+
for (const set of this.ir.pronounSets) {
|
|
503
|
+
registry.registerPronounSet(set.name, set.forms);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
364
506
|
}
|
|
365
507
|
getCustomVocabulary() {
|
|
366
508
|
return { verbs: this.ir.verbs.map((v) => toVocabularyVerb(v)) };
|
|
@@ -379,6 +521,85 @@ class ChordStory {
|
|
|
379
521
|
* state is world state — no runner-state plumbing (design.md §6).
|
|
380
522
|
*/
|
|
381
523
|
onEngineReady(engine) {
|
|
524
|
+
// ADR-216 `client has`: wire the LIVE capability source (the engine
|
|
525
|
+
// negotiates capabilities at start(); reads happen per evaluation).
|
|
526
|
+
// Engines without the accessor leave the text-only default in place.
|
|
527
|
+
if (engine.getClientCapabilities) {
|
|
528
|
+
this.evaluator.setCapabilitiesProvider(() => engine.getClientCapabilities());
|
|
529
|
+
}
|
|
530
|
+
// ADR-215 Q4: NPCs are CORE — the plugin auto-wires unconditionally
|
|
531
|
+
// (unlike the scheduler's daemon-gated registration below), and each
|
|
532
|
+
// factory-configured behavior registers under its per-entity id.
|
|
533
|
+
const npcPlugin = new plugin_npc_1.NpcPlugin();
|
|
534
|
+
engine.getPluginRegistry().register(npcPlugin);
|
|
535
|
+
const npcService = npcPlugin.getNpcService();
|
|
536
|
+
for (const pending of this.npcBehaviors) {
|
|
537
|
+
npcService.registerBehavior(this.buildNpcBehavior(pending));
|
|
538
|
+
}
|
|
539
|
+
// ADR-215 `use state-machines`: the plugin registers engine-side and
|
|
540
|
+
// every `define machine` lowers into its registry (Chord conditions
|
|
541
|
+
// ride as custom guards, Chord bodies as custom effects). Machines in
|
|
542
|
+
// rogue IR without the `use` are a LoadError, never silently dead.
|
|
543
|
+
if ((this.ir.machines ?? []).length > 0 && !(this.ir.uses ?? []).includes('state-machines')) {
|
|
544
|
+
throw new errors_js_1.LoadError('`define machine` needs `use state-machines` in the story header.', this.ir.machines[0].span);
|
|
545
|
+
}
|
|
546
|
+
if ((this.ir.uses ?? []).includes('state-machines')) {
|
|
547
|
+
const smPlugin = new plugin_state_machine_1.StateMachinePlugin();
|
|
548
|
+
engine.getPluginRegistry().register(smPlugin);
|
|
549
|
+
const smRegistry = smPlugin.getRegistry();
|
|
550
|
+
for (const machine of this.ir.machines ?? []) {
|
|
551
|
+
const { definition, bindings } = this.buildMachineDefinition(machine);
|
|
552
|
+
smRegistry.register(definition, bindings);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// ADR-260 D6: every `use`d extension gets its `registerPlugin` slot
|
|
556
|
+
// invoked here — the only moment a plugin registry exists. Generic over
|
|
557
|
+
// `ir.uses` and naming no extension, so enabling one is a registry entry
|
|
558
|
+
// rather than a loader edit. `state-machines` declines the slot above
|
|
559
|
+
// because it must retain the plugin instance to lower `define machine`
|
|
560
|
+
// blocks into it; nothing here needs lowering after construction.
|
|
561
|
+
for (const name of this.ir.uses ?? []) {
|
|
562
|
+
extension_registry_js_1.EXTENSION_REGISTRY.get(name)?.registerPlugin?.(engine.getPluginRegistry());
|
|
563
|
+
}
|
|
564
|
+
// ADR-261 D7 (amended by ADR-262 D3): every crossed rung speaks — its `says`
|
|
565
|
+
// phrase or the overridable platform fallback — so the narrator registers
|
|
566
|
+
// whenever a ladder exists, not only when some rung has a phrase. Gated on
|
|
567
|
+
// `ir.ranks` (generic IR, not an extension name). `announce silent` still
|
|
568
|
+
// suppresses output; the narrator simply emits nothing in that mode.
|
|
569
|
+
if (this.ir.ranks.length > 0) {
|
|
570
|
+
engine.getPluginRegistry().register(this.buildPromotionNarrator());
|
|
571
|
+
}
|
|
572
|
+
// ADR-263: the hunger meter — ADR-262's second consumer. Its eating handler
|
|
573
|
+
// is installed via EXTENSION_REGISTRY.registerWorld; the config-dependent
|
|
574
|
+
// parts lower here from `ir.hunger`, where grows/fatal/phrases and
|
|
575
|
+
// `killPlayer` are in reach (the registry map cannot carry them).
|
|
576
|
+
if (this.ir.hunger) {
|
|
577
|
+
const h = this.ir.hunger;
|
|
578
|
+
const registry = engine.getPluginRegistry();
|
|
579
|
+
const bands = h.rungs.map((r) => ({
|
|
580
|
+
id: r.id,
|
|
581
|
+
threshold: r.threshold,
|
|
582
|
+
name: r.id,
|
|
583
|
+
phraseId: r.phraseKey,
|
|
584
|
+
}));
|
|
585
|
+
// Decay + death daemon (priority above the watcher/narrator so severity is
|
|
586
|
+
// current when they observe it this turn).
|
|
587
|
+
registry.register(this.buildHungerDaemon(h.grows ?? 0, h.fatal));
|
|
588
|
+
// The ADR-262 data watcher — `band_crossed` over the severity scalar.
|
|
589
|
+
registry.register((0, ext_hunger_1.createHungerCrossingWatcher)(bands));
|
|
590
|
+
// The Chord narrator: author `says` phrase or the overridable fallback,
|
|
591
|
+
// under `use hunger, announce <mode>` (default `all`).
|
|
592
|
+
registry.register((0, plugins_1.createBandNarrator)({
|
|
593
|
+
id: 'chord.story.hunger-narrator',
|
|
594
|
+
priority: 20,
|
|
595
|
+
concept: 'hunger',
|
|
596
|
+
value: (world) => (0, ext_hunger_1.getHungerSeverity)(world),
|
|
597
|
+
bands: () => bands,
|
|
598
|
+
mode: (this.ir.announceModes?.['hunger'] ?? 'all'),
|
|
599
|
+
narrationEventId: 'if.event.hunger_narrated',
|
|
600
|
+
fallbackPhraseId: 'if.action.hunger.crossed',
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
382
603
|
const daemons = this.runtime.buildSchedulerDaemons();
|
|
383
604
|
if (daemons.length > 0) {
|
|
384
605
|
const plugin = new plugin_scheduler_1.SchedulerPlugin();
|
|
@@ -399,6 +620,146 @@ class ChordStory {
|
|
|
399
620
|
// built-in contributor evaluates them.
|
|
400
621
|
this.registerPresentEntries(engine);
|
|
401
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* The promotion narrator — the Chord render layer over the ADR-262 crossing
|
|
625
|
+
* engine (ADR-261 D7, amended by ADR-262 D3).
|
|
626
|
+
*
|
|
627
|
+
* A promotion *says* the rung's authored `says` phrase; a rung with **no**
|
|
628
|
+
* `says` now speaks the overridable platform fallback
|
|
629
|
+
* (`if.action.scoring.promotion`), because ADR-262 D3 made silence explicit —
|
|
630
|
+
* only `announce silent` suppresses. This is a thin {@link createBandNarrator}
|
|
631
|
+
* over the score scalar: it renders each crossed rung (`all` mode) so a
|
|
632
|
+
* multi-band jump reports each elevation (ADR-262 D6), mapping rank ids to
|
|
633
|
+
* their `says` keys.
|
|
634
|
+
*
|
|
635
|
+
* **Why the engine derives the crossing rather than observing an event.** It
|
|
636
|
+
* hands each plugin only the *action's* events (`TurnPluginContext.actionEvents`
|
|
637
|
+
* is a fixed snapshot taken before the plugin loop), so no plugin can see
|
|
638
|
+
* another's output — `ext-scoring`'s data watcher runs in the same loop and is
|
|
639
|
+
* invisible here. Both read the same derived ledger, so they cannot disagree
|
|
640
|
+
* about whether a rung was crossed; what differs is only what each produces —
|
|
641
|
+
* the platform its `band_crossed` event, the story its sentence.
|
|
642
|
+
*
|
|
643
|
+
* Registered by the Chord loader because only it holds the IR. `phraseKey`
|
|
644
|
+
* never crosses into a platform type: `RankDefinition` carries none (ADR-260
|
|
645
|
+
* D2), and the map below stays in this closure.
|
|
646
|
+
*/
|
|
647
|
+
buildPromotionNarrator() {
|
|
648
|
+
const phraseByRankId = new Map();
|
|
649
|
+
for (const rung of this.ir.ranks) {
|
|
650
|
+
if (rung.phraseKey !== undefined)
|
|
651
|
+
phraseByRankId.set(rung.id, rung.phraseKey);
|
|
652
|
+
}
|
|
653
|
+
return (0, plugins_1.createBandNarrator)({
|
|
654
|
+
id: 'chord.story.promotion-narrator',
|
|
655
|
+
// Below ext-scoring's watcher (25): the sentence follows the event.
|
|
656
|
+
priority: 20,
|
|
657
|
+
concept: 'rank',
|
|
658
|
+
isEnabled: (world) => world.isScoringEnabled(),
|
|
659
|
+
value: (world) => world.getScore(),
|
|
660
|
+
bands: (world) => world.getRanks().map((r) => ({
|
|
661
|
+
id: r.id,
|
|
662
|
+
threshold: r.threshold,
|
|
663
|
+
name: r.name,
|
|
664
|
+
phraseId: phraseByRankId.get(r.id),
|
|
665
|
+
})),
|
|
666
|
+
// The bottom rung is the starting position — seed it silently.
|
|
667
|
+
seedAtOrBelow: 0,
|
|
668
|
+
// ADR-262 D3: `use scoring, announce <mode>`; default `all` reports each
|
|
669
|
+
// elevation on a multi-band jump (ADR-262 D6). The analyzer validated it.
|
|
670
|
+
mode: (this.ir.announceModes?.['scoring'] ?? 'all'),
|
|
671
|
+
narrationEventId: 'if.event.rank_narrated',
|
|
672
|
+
// ADR-262 D3: spoken when a rung has no `says`. Overridable via
|
|
673
|
+
// `override message scoring-promotion`.
|
|
674
|
+
fallbackPhraseId: 'if.action.scoring.promotion',
|
|
675
|
+
// Preserve scoring's authored `{rank}` / `{score}` phrase params.
|
|
676
|
+
paramsFor: (rung, span) => ({ rank: rung.name, score: span.value }),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* The hunger decay + death daemon (ADR-263 D1). Each turn it raises the
|
|
681
|
+
* severity counter by `grows` (the `on every turn` mechanic) and, once
|
|
682
|
+
* severity reaches `fatal`, kills the player (`kill the player` — a raw-value
|
|
683
|
+
* trigger, not a band). Runs at a higher priority than the crossing watcher
|
|
684
|
+
* and narrator so they observe the updated severity the same turn.
|
|
685
|
+
*/
|
|
686
|
+
buildHungerDaemon(grows, fatal) {
|
|
687
|
+
return {
|
|
688
|
+
id: 'chord.story.hunger-daemon',
|
|
689
|
+
priority: 30,
|
|
690
|
+
onAfterAction(ctx) {
|
|
691
|
+
if (grows > 0) {
|
|
692
|
+
(0, ext_hunger_1.setHungerSeverity)(ctx.world, (0, ext_hunger_1.getHungerSeverity)(ctx.world) + grows);
|
|
693
|
+
}
|
|
694
|
+
if (fatal !== undefined && (0, ext_hunger_1.getHungerSeverity)(ctx.world) >= fatal) {
|
|
695
|
+
const player = ctx.world.getPlayer();
|
|
696
|
+
if (player) {
|
|
697
|
+
// The death line is lang-en-us prose (overridable `hunger-starved`),
|
|
698
|
+
// routed through the death event's messageId — not a hardcoded string.
|
|
699
|
+
const event = (0, stdlib_1.killPlayer)(ctx.world, player, {
|
|
700
|
+
cause: 'starvation',
|
|
701
|
+
messageId: 'if.action.hunger.starved',
|
|
702
|
+
terminal: true,
|
|
703
|
+
});
|
|
704
|
+
return event ? [event] : [];
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return [];
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* ADR-216 custom channels + ADR-215's third contribution part: every
|
|
713
|
+
* `define channel` lowers to a real IOChannel (JSON data projection —
|
|
714
|
+
* the turn's last event of the declared type, `take` fields projected
|
|
715
|
+
* from its data), and every `use`d extension gets its reserved
|
|
716
|
+
* `registerChannels` slot invoked (no bundled extension registers one
|
|
717
|
+
* today — the leg is live but unexercised; a novel renderer would ship
|
|
718
|
+
* there, keeping stories pure IR). The engine invokes this hook once at
|
|
719
|
+
* start (`Story.registerChannels`, engine/src/game-engine.ts).
|
|
720
|
+
*
|
|
721
|
+
* ADR-241 D4: family channels (`define ambient`/`define layer`, plus
|
|
722
|
+
* the implied `main` bed) register through stdlib's family builders —
|
|
723
|
+
* `ambient:<word>` / `image:<word>` ids, capability gates inherited
|
|
724
|
+
* from the builders (`sound` / `images`). Data channels are untouched.
|
|
725
|
+
*/
|
|
726
|
+
registerChannels(registry) {
|
|
727
|
+
for (const channel of this.ir.channels ?? []) {
|
|
728
|
+
if (channel.family !== 'data') {
|
|
729
|
+
registry.add(channel.family === 'ambient'
|
|
730
|
+
? (0, stdlib_1.createAmbientChannel)(channel.name)
|
|
731
|
+
: (0, stdlib_1.createImageChannel)(channel.name));
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const { returns } = channel;
|
|
735
|
+
// ADR-256: the IR `fromEvent` is a dotless Chord id; match against the
|
|
736
|
+
// platform runtime type (media.* → dotted; author events pass through),
|
|
737
|
+
// the same translation the emit seam applies, so the two always agree.
|
|
738
|
+
const fromEvent = (0, event_id_map_js_1.translateEventId)(channel.fromEvent);
|
|
739
|
+
const definition = {
|
|
740
|
+
id: channel.name,
|
|
741
|
+
contentType: 'json',
|
|
742
|
+
mode: channel.mode,
|
|
743
|
+
emit: 'sparse',
|
|
744
|
+
...(channel.gatedBy ? { gatedBy: channel.gatedBy } : {}),
|
|
745
|
+
produce: (ctx) => {
|
|
746
|
+
for (let i = ctx.events.length - 1; i >= 0; i--) {
|
|
747
|
+
const event = ctx.events[i];
|
|
748
|
+
if (event.type !== fromEvent)
|
|
749
|
+
continue;
|
|
750
|
+
const data = (event.data ?? {});
|
|
751
|
+
const value = this.evaluateChannelReturn(returns, data);
|
|
752
|
+
return channel.mode === 'append' ? [value] : value;
|
|
753
|
+
}
|
|
754
|
+
return undefined;
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
registry.add(definition);
|
|
758
|
+
}
|
|
759
|
+
for (const name of this.ir.uses ?? []) {
|
|
760
|
+
extension_registry_js_1.EXTENSION_REGISTRY.get(name)?.registerChannels?.(registry);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
402
763
|
/**
|
|
403
764
|
* The deadly-exit command transformer (ADR-227). Redirects `going
|
|
404
765
|
* <deadly-direction>` from a room with declared deadly exits to
|
|
@@ -468,13 +829,13 @@ class ChordStory {
|
|
|
468
829
|
const ownerWorldId = this.worldIds.get(irEntity.id);
|
|
469
830
|
if (!ownerWorldId)
|
|
470
831
|
continue;
|
|
471
|
-
const texts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0,
|
|
832
|
+
const texts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_js_1.withLineBreaks)(v.text)));
|
|
472
833
|
const content = texts.length === 1 && !phrase.strategy
|
|
473
834
|
? { kind: 'literal', text: texts[0] }
|
|
474
835
|
: {
|
|
475
836
|
kind: 'choice',
|
|
476
837
|
alternatives: texts.map((text) => ({ kind: 'literal', text })),
|
|
477
|
-
selector:
|
|
838
|
+
selector: runtime_js_1.STRATEGY_SELECTOR[phrase.strategy ?? 'cycling'],
|
|
478
839
|
// ADR-212 §4 caller contract: entityId = owner, messageKey =
|
|
479
840
|
// counterKey — the platform warns on a mismatch.
|
|
480
841
|
entityId: ownerWorldId,
|
|
@@ -512,6 +873,14 @@ class ChordStory {
|
|
|
512
873
|
extendParser(parser) {
|
|
513
874
|
const grammar = parser.getStoryGrammar();
|
|
514
875
|
for (const action of this.ir.actions) {
|
|
876
|
+
// Bare-verb forms (platform-issue-sweep Phase 8 #13, David's ruling:
|
|
877
|
+
// ALL dispatch actions): dispatch validate fully handles the no-target
|
|
878
|
+
// case (authored `refuse without` arm, else the platform default), but
|
|
879
|
+
// compiler-emitted grammar always carried the slot, so a bare `lower`
|
|
880
|
+
// (or friendly-zoo's `pet`/`feed`) never parsed far enough to reach it.
|
|
881
|
+
// Register each pattern's literal prefix as its own rule, below the
|
|
882
|
+
// slotted forms so they win whenever a target is named.
|
|
883
|
+
const bareForms = new Set();
|
|
515
884
|
for (const pattern of action.patterns) {
|
|
516
885
|
if (pattern.cardinality)
|
|
517
886
|
continue; // `→ each …` expansion is engine-owned (Phase C)
|
|
@@ -519,6 +888,18 @@ class ChordStory {
|
|
|
519
888
|
.map((part) => (part.kind === 'slot' ? `:${part.word}` : part.word))
|
|
520
889
|
.join(' ');
|
|
521
890
|
grammar.define(text).mapsTo(`chord.action.${action.name}`).withPriority(150).build();
|
|
891
|
+
const slotIndex = pattern.parts.findIndex((part) => part.kind === 'slot');
|
|
892
|
+
if (slotIndex > 0) {
|
|
893
|
+
const bare = pattern.parts
|
|
894
|
+
.slice(0, slotIndex)
|
|
895
|
+
.map((part) => part.word)
|
|
896
|
+
.join(' ');
|
|
897
|
+
if (bare)
|
|
898
|
+
bareForms.add(bare);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
for (const bare of bareForms) {
|
|
902
|
+
grammar.define(bare).mapsTo(`chord.action.${action.name}`).withPriority(140).build();
|
|
522
903
|
}
|
|
523
904
|
}
|
|
524
905
|
}
|
|
@@ -541,87 +922,181 @@ class ChordStory {
|
|
|
541
922
|
};
|
|
542
923
|
}
|
|
543
924
|
// ------------------------------------------------------- entity build
|
|
925
|
+
/** Region IR id → parent region IR id (the parent's `containing` lists the child — ADR-236 D3). */
|
|
926
|
+
regionParents = new Map();
|
|
927
|
+
/**
|
|
928
|
+
* Region entities in parent-first order, filling `regionParents` on the
|
|
929
|
+
* way (ADR-236 D3). The compiler's cycle gate guarantees the walk ends; a
|
|
930
|
+
* cycle here means rogue IR and is a LoadError, never a hang.
|
|
931
|
+
*/
|
|
932
|
+
regionsInParentFirstOrder() {
|
|
933
|
+
const regions = this.ir.entities.filter((e) => !e.isPlayer && e.kinds.some((k) => k.name === 'region'));
|
|
934
|
+
const byId = new Map(regions.map((r) => [r.id, r]));
|
|
935
|
+
this.regionParents.clear();
|
|
936
|
+
for (const region of regions) {
|
|
937
|
+
for (const member of region.containing ?? []) {
|
|
938
|
+
if (byId.has(member.id))
|
|
939
|
+
this.regionParents.set(member.id, region.id);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const ordered = [];
|
|
943
|
+
const state = new Map();
|
|
944
|
+
const visit = (region) => {
|
|
945
|
+
const s = state.get(region.id);
|
|
946
|
+
if (s === 'done')
|
|
947
|
+
return;
|
|
948
|
+
if (s === 'visiting') {
|
|
949
|
+
throw new errors_js_1.LoadError(`Region containment cycle at \`${region.name}\` — the compiler gate should have refused this story.`, region.span);
|
|
950
|
+
}
|
|
951
|
+
state.set(region.id, 'visiting');
|
|
952
|
+
const parentId = this.regionParents.get(region.id);
|
|
953
|
+
const parent = parentId ? byId.get(parentId) : undefined;
|
|
954
|
+
if (parent)
|
|
955
|
+
visit(parent);
|
|
956
|
+
state.set(region.id, 'done');
|
|
957
|
+
ordered.push(region);
|
|
958
|
+
};
|
|
959
|
+
for (const region of regions)
|
|
960
|
+
visit(region);
|
|
961
|
+
return ordered;
|
|
962
|
+
}
|
|
544
963
|
buildEntity(world, irEntity) {
|
|
545
964
|
if (irEntity.kinds.length > 1) {
|
|
546
|
-
throw new
|
|
965
|
+
throw new errors_js_1.LoadError(`\`${irEntity.name}\` declares more than one kind noun.`, irEntity.span);
|
|
547
966
|
}
|
|
548
967
|
const kind = irEntity.kinds[0]?.name ?? null;
|
|
549
968
|
const description = irEntity.descriptionKey ? this.phraseText(irEntity.descriptionKey) : undefined;
|
|
550
|
-
|
|
969
|
+
// ADR-237 D3: direct trait composition — the loader builds on the
|
|
970
|
+
// world-model surface itself; `@sharpee/helpers` is author-facing only.
|
|
971
|
+
const aliases = irEntity.aka.length ? irEntity.aka : undefined;
|
|
551
972
|
let entity;
|
|
552
973
|
switch (kind) {
|
|
553
974
|
case 'room': {
|
|
554
|
-
const builder = h.room(irEntity.name);
|
|
555
|
-
if (description)
|
|
556
|
-
builder.description(description);
|
|
557
975
|
// Z1: `first time` prose → RoomTrait.initialDescription (first look
|
|
558
976
|
// shows it, later looks show the standard description — stdlib's
|
|
559
977
|
// looking-data reads the field; no stdlib change).
|
|
560
978
|
const initialDescription = irEntity.initialDescriptionKey
|
|
561
979
|
? this.phraseText(irEntity.initialDescriptionKey)
|
|
562
980
|
: undefined;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
entity = builder.build();
|
|
981
|
+
entity = world.createEntity(irEntity.name, 'room');
|
|
982
|
+
entity.add(new world_model_1.RoomTrait({
|
|
983
|
+
requiresLight: irEntity.traits.some((t) => t.name === 'dark' && t.condition === null),
|
|
984
|
+
initialDescription,
|
|
985
|
+
}));
|
|
986
|
+
entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
|
|
570
987
|
break;
|
|
571
988
|
}
|
|
572
989
|
case 'container': {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
entity = builder.build();
|
|
990
|
+
// NOTE: no OpenableTrait/LockableTrait pre-adds — applyTraitAdjectives
|
|
991
|
+
// owns both compositions uniformly. For lockable, a keyless pre-add
|
|
992
|
+
// made the keyed re-add skip, dropping `with key X` config (ADR-230
|
|
993
|
+
// Phase 9a). For openable, the pre-add carried an open-by-default,
|
|
994
|
+
// splitting container-kind entities from adjective-only ones; ADR-231
|
|
995
|
+
// D5b removed it so OpenableTrait's default (closed) is authoritative
|
|
996
|
+
// everywhere — `starts open` is the author's escape hatch.
|
|
997
|
+
entity = world.createEntity(irEntity.name, 'object');
|
|
998
|
+
entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
|
|
999
|
+
entity.add(new world_model_1.ContainerTrait());
|
|
584
1000
|
this.applyContainerConfig(entity, irEntity.kinds[0]);
|
|
585
1001
|
break;
|
|
586
1002
|
}
|
|
587
1003
|
case 'person': {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1004
|
+
entity = world.createEntity(irEntity.name, 'actor');
|
|
1005
|
+
entity.add(new world_model_1.ActorTrait());
|
|
1006
|
+
// ADR-242 D2/D3: `proper` → the player's own proper-name shape
|
|
1007
|
+
// (properName + empty article); otherwise the plain IdentityTrait
|
|
1008
|
+
// defaults stand ('a', contextual articles). The old helpers-era
|
|
1009
|
+
// `article: undefined` pin is gone — no loader path constructs an
|
|
1010
|
+
// undefined article. `pronouns` maps to pronounSet only when
|
|
1011
|
+
// declared (ruled Q-2: no injected default — by-number fallback).
|
|
1012
|
+
const proper = irEntity.traits.some((t) => t.name === 'proper' && t.condition === null);
|
|
1013
|
+
entity.add(new world_model_1.IdentityTrait({
|
|
1014
|
+
name: irEntity.name,
|
|
1015
|
+
description,
|
|
1016
|
+
aliases,
|
|
1017
|
+
...(proper ? { properName: true, article: '' } : {}),
|
|
1018
|
+
...(irEntity.pronouns !== undefined ? { pronounSet: irEntity.pronouns } : {}),
|
|
1019
|
+
}));
|
|
594
1020
|
break;
|
|
595
1021
|
}
|
|
596
1022
|
case 'supporter': {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
builder.description(description);
|
|
600
|
-
if (irEntity.aka.length)
|
|
601
|
-
builder.aliases(...irEntity.aka);
|
|
602
|
-
entity = builder.build();
|
|
1023
|
+
entity = world.createEntity(irEntity.name, 'object');
|
|
1024
|
+
entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
|
|
603
1025
|
entity.add(new world_model_1.SupporterTrait({ capacity: supporterCapacity(irEntity.kinds[0]) }));
|
|
604
1026
|
break;
|
|
605
1027
|
}
|
|
606
|
-
case '
|
|
607
|
-
|
|
1028
|
+
case 'region': {
|
|
1029
|
+
// ADR-236 D1: built on the shipped platform seam — createRegion +
|
|
1030
|
+
// assignRoom ARE the shared mechanics (RoomTrait.regionId is never
|
|
1031
|
+
// set directly). Pass 0 built parents first, so a nested child's
|
|
1032
|
+
// parentRegionId resolves here.
|
|
1033
|
+
const parentIr = this.regionParents.get(irEntity.id);
|
|
1034
|
+
const parentRegionId = parentIr ? this.worldIds.get(parentIr) : undefined;
|
|
1035
|
+
entity = world.createRegion(`rg-${irEntity.id}`, {
|
|
1036
|
+
name: irEntity.name,
|
|
1037
|
+
...(parentRegionId ? { parentRegionId } : {}),
|
|
1038
|
+
});
|
|
1039
|
+
// A region block composes like any entity block (aka, description):
|
|
1040
|
+
// both live on IdentityTrait; whether any action surfaces them is
|
|
1041
|
+
// the platform's business (ADR-236 D1).
|
|
1042
|
+
entity.add(new world_model_1.IdentityTrait({
|
|
1043
|
+
name: irEntity.name,
|
|
1044
|
+
...(description ? { description } : {}),
|
|
1045
|
+
aliases: irEntity.aka,
|
|
1046
|
+
article: irEntity.article ?? 'the',
|
|
1047
|
+
}));
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
case 'door': {
|
|
1051
|
+
// ADR-234 D4: SceneryTrait + OpenableTrait starting closed compose
|
|
1052
|
+
// automatically (createDoor parity; `starts open` is the author's
|
|
1053
|
+
// override via applyStartsStates). DoorTrait is attached at exit
|
|
1054
|
+
// wiring — its room pair comes from the `through` exit line, and
|
|
1055
|
+
// the trait's constructor requires both rooms.
|
|
1056
|
+
entity = world.createEntity(irEntity.name, 'door');
|
|
1057
|
+
entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
|
|
1058
|
+
entity.add(new world_model_1.SceneryTrait());
|
|
1059
|
+
entity.add(new world_model_1.OpenableTrait({ isOpen: false }));
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
608
1062
|
case null: {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
builder.description(description);
|
|
612
|
-
if (irEntity.aka.length)
|
|
613
|
-
builder.aliases(...irEntity.aka);
|
|
614
|
-
entity = builder.build();
|
|
1063
|
+
entity = world.createEntity(irEntity.name, 'object');
|
|
1064
|
+
entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
|
|
615
1065
|
break;
|
|
616
1066
|
}
|
|
617
1067
|
default:
|
|
618
|
-
throw new
|
|
1068
|
+
throw new errors_js_1.LoadError(`\`${irEntity.name}\`: unknown kind noun \`${kind}\`.`, irEntity.span);
|
|
619
1069
|
}
|
|
620
1070
|
this.applyTraitAdjectives(entity, irEntity, kind);
|
|
1071
|
+
this.applyStartsStates(entity, irEntity);
|
|
621
1072
|
this.worldIds.set(irEntity.id, entity.id);
|
|
622
1073
|
this.irIds.set(entity.id, irEntity.id);
|
|
623
1074
|
return entity;
|
|
624
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* ADR-231 D5a: map each accepted `starts <state>` initializer to the
|
|
1078
|
+
* paired trait's initial-value field (locked→isLocked:true, closed→
|
|
1079
|
+
* isOpen:false, on→isOn:true, …). Runs AFTER trait composition (the
|
|
1080
|
+
* adjective-composed traits, ADR-231 D5b: there are no builder pre-adds
|
|
1081
|
+
* left) — so a declared initializer always wins over any
|
|
1082
|
+
* trait default. Only the trait boolean is set; the state adjective
|
|
1083
|
+
* itself is never stored story state (the shadow-state ratchet).
|
|
1084
|
+
* The analyzer's pairing gate guarantees the trait is present; a missing
|
|
1085
|
+
* trait here is a defect, reported as a LoadError, never a silent skip.
|
|
1086
|
+
*/
|
|
1087
|
+
applyStartsStates(entity, irEntity) {
|
|
1088
|
+
for (const state of irEntity.startsStates ?? []) {
|
|
1089
|
+
const mapping = STARTS_STATE_TRAIT_FIELDS.get(state);
|
|
1090
|
+
if (!mapping) {
|
|
1091
|
+
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);
|
|
1092
|
+
}
|
|
1093
|
+
const trait = entity.get(mapping.traitType);
|
|
1094
|
+
if (!trait) {
|
|
1095
|
+
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);
|
|
1096
|
+
}
|
|
1097
|
+
trait[mapping.field] = mapping.value;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
625
1100
|
/**
|
|
626
1101
|
* Resolve trait-config entity references (`with tool X`, `with key X`)
|
|
627
1102
|
* to world entity ids (ADR-230 D3c / Phase 9a) — runs once, after every
|
|
@@ -631,7 +1106,7 @@ class ChordStory {
|
|
|
631
1106
|
for (const pending of this.pendingEntityRefs) {
|
|
632
1107
|
const worldId = this.worldIds.get(pending.irRefId);
|
|
633
1108
|
if (!worldId) {
|
|
634
|
-
throw new
|
|
1109
|
+
throw new errors_js_1.LoadError(`\`${pending.ownerName}\`: a trait-config entity reference was never built.`, pending.span);
|
|
635
1110
|
}
|
|
636
1111
|
pending.apply(worldId);
|
|
637
1112
|
}
|
|
@@ -678,10 +1153,10 @@ class ChordStory {
|
|
|
678
1153
|
surfaces++;
|
|
679
1154
|
}
|
|
680
1155
|
if (surfaces === 0) {
|
|
681
|
-
throw new
|
|
1156
|
+
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
1157
|
}
|
|
683
1158
|
if (surfaces > 1) {
|
|
684
|
-
throw new
|
|
1159
|
+
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
1160
|
}
|
|
686
1161
|
}
|
|
687
1162
|
}
|
|
@@ -698,12 +1173,18 @@ class ChordStory {
|
|
|
698
1173
|
continue;
|
|
699
1174
|
const def = this.ir.traits.find((t) => t.name === trait.name);
|
|
700
1175
|
if (def && def.onClauses.length > 0 && def.onClauses.every((c) => c.binding === 'every-turn')) {
|
|
701
|
-
entity.add(new ChordDataTrait(
|
|
1176
|
+
entity.add(new ChordDataTrait(state_keys_js_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
|
|
702
1177
|
continue;
|
|
703
1178
|
}
|
|
704
|
-
throw new
|
|
1179
|
+
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
1180
|
}
|
|
706
1181
|
switch (trait.name) {
|
|
1182
|
+
case 'proper':
|
|
1183
|
+
// ADR-242 D1: identity configuration, consumed by the person
|
|
1184
|
+
// branch's IdentityTrait construction (like the room branch's
|
|
1185
|
+
// `dark`) — without this case it would fall through to the
|
|
1186
|
+
// authored-trait default and mint a spurious ChordDataTrait.
|
|
1187
|
+
break;
|
|
707
1188
|
case 'scenery':
|
|
708
1189
|
if (!entity.has(world_model_1.TraitType.SCENERY))
|
|
709
1190
|
entity.add(new world_model_1.SceneryTrait());
|
|
@@ -714,18 +1195,38 @@ class ChordStory {
|
|
|
714
1195
|
case 'readable':
|
|
715
1196
|
entity.add(new world_model_1.ReadableTrait({ text: configValue(trait, 'text') ?? '' }));
|
|
716
1197
|
break;
|
|
717
|
-
case 'openable':
|
|
718
|
-
|
|
719
|
-
|
|
1198
|
+
case 'openable': {
|
|
1199
|
+
// Defect D3 fix (2026-07-17, ratchet G4): `openable with the
|
|
1200
|
+
// crowbar` (keyless per R3) gates opening on holding the tool
|
|
1201
|
+
// (OpenableTrait.toolId, ADR-230 D3b). Name → world id via the
|
|
1202
|
+
// shared pending mechanism — never the raw display-name string
|
|
1203
|
+
// (the lockable-bug class).
|
|
1204
|
+
if (entity.has(world_model_1.TraitType.OPENABLE))
|
|
1205
|
+
break;
|
|
1206
|
+
const openable = new world_model_1.OpenableTrait();
|
|
1207
|
+
const openToolName = entityConfigValue(trait);
|
|
1208
|
+
if (openToolName !== undefined) {
|
|
1209
|
+
this.pendingEntityRefs.push(this.entityRefFor(openToolName, 'tool', irEntity, trait.span, (worldId) => {
|
|
1210
|
+
openable.toolId = worldId;
|
|
1211
|
+
}));
|
|
1212
|
+
}
|
|
1213
|
+
entity.add(openable);
|
|
720
1214
|
break;
|
|
1215
|
+
}
|
|
721
1216
|
case 'lockable': {
|
|
722
|
-
// ADR-230 Phase 9a:
|
|
723
|
-
//
|
|
724
|
-
//
|
|
1217
|
+
// ADR-230 Phase 9a: the key entity (`lockable with the iron key`,
|
|
1218
|
+
// keyless per R3) resolves name → world id through the shared
|
|
1219
|
+
// pending mechanism (forward refs legal) — the raw display-name
|
|
1220
|
+
// string never reaches LockableTrait.keyId.
|
|
725
1221
|
if (entity.has(world_model_1.TraitType.LOCKABLE))
|
|
726
1222
|
break;
|
|
727
|
-
|
|
728
|
-
|
|
1223
|
+
// ADR-234 D4 kind-scoped default: a lockable DOOR starts locked
|
|
1224
|
+
// (the IF convention; createDoor's `isLocked ?? true` parity) —
|
|
1225
|
+
// everywhere else the trait-wide default (unlocked) stands.
|
|
1226
|
+
// `starts unlocked` is the author's override: applyStartsStates
|
|
1227
|
+
// runs after composition, so a declared initializer always wins.
|
|
1228
|
+
const lockable = new world_model_1.LockableTrait(kind === 'door' ? { isLocked: true } : {});
|
|
1229
|
+
const keyName = entityConfigValue(trait);
|
|
729
1230
|
if (keyName !== undefined) {
|
|
730
1231
|
this.pendingEntityRefs.push(this.entityRefFor(keyName, 'key', irEntity, trait.span, (worldId) => {
|
|
731
1232
|
lockable.keyId = worldId;
|
|
@@ -743,7 +1244,7 @@ class ChordStory {
|
|
|
743
1244
|
if (entity.has(traitType))
|
|
744
1245
|
break;
|
|
745
1246
|
const toolGated = trait.name === 'cuttable' ? new world_model_1.CuttableTrait() : new world_model_1.DiggableTrait();
|
|
746
|
-
const toolName =
|
|
1247
|
+
const toolName = entityConfigValue(trait);
|
|
747
1248
|
if (toolName !== undefined) {
|
|
748
1249
|
this.pendingEntityRefs.push(this.entityRefFor(toolName, 'tool', irEntity, trait.span, (worldId) => {
|
|
749
1250
|
toolGated.toolId = worldId;
|
|
@@ -756,8 +1257,56 @@ class ChordStory {
|
|
|
756
1257
|
entity.add(new world_model_1.SwitchableTrait());
|
|
757
1258
|
break;
|
|
758
1259
|
case 'edible':
|
|
759
|
-
|
|
1260
|
+
// Guarded so `edible, drinkable` composes order-independently —
|
|
1261
|
+
// a bare re-add here would drop drinkable's liquid flag.
|
|
1262
|
+
if (!entity.has(world_model_1.TraitType.EDIBLE))
|
|
1263
|
+
entity.add(new world_model_1.EdibleTrait());
|
|
1264
|
+
break;
|
|
1265
|
+
case 'drinkable': {
|
|
1266
|
+
// Ratchet G1 (2026-07-17): the liquid marker — EDIBLE.liquid is
|
|
1267
|
+
// what routes the entity to drinking instead of eating.
|
|
1268
|
+
const edible = entity.get(world_model_1.TraitType.EDIBLE);
|
|
1269
|
+
if (edible)
|
|
1270
|
+
edible.liquid = true;
|
|
1271
|
+
else
|
|
1272
|
+
entity.add(new world_model_1.EdibleTrait({ liquid: true }));
|
|
1273
|
+
break;
|
|
1274
|
+
}
|
|
1275
|
+
case 'pushable':
|
|
1276
|
+
// Defect D1 fix (2026-07-17): the catalog accepted these two but
|
|
1277
|
+
// the loader had no case — `--check` passed stories that load
|
|
1278
|
+
// rejected. Default config (button-style, repeatable).
|
|
1279
|
+
if (!entity.has(world_model_1.TraitType.PUSHABLE))
|
|
1280
|
+
entity.add(new world_model_1.PushableTrait({}));
|
|
1281
|
+
break;
|
|
1282
|
+
case 'pullable':
|
|
1283
|
+
if (!entity.has(world_model_1.TraitType.PULLABLE))
|
|
1284
|
+
entity.add(new world_model_1.PullableTrait({}));
|
|
1285
|
+
break;
|
|
1286
|
+
case 'concealed': {
|
|
1287
|
+
// Ratchet G2 (2026-07-17): marker adjective — hidden from normal
|
|
1288
|
+
// view until searching reveals it (IdentityTrait.concealed).
|
|
1289
|
+
const identity = entity.get(world_model_1.TraitType.IDENTITY);
|
|
1290
|
+
if (identity)
|
|
1291
|
+
identity.concealed = true;
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
case 'hiding-spot': {
|
|
1295
|
+
// Ratchet G3 (2026-07-17): bare = the actor may hide at any
|
|
1296
|
+
// position; `with position <word>` narrows to exactly one.
|
|
1297
|
+
const HIDING_POSITIONS = ['behind', 'under', 'on', 'inside'];
|
|
1298
|
+
const position = configValue(trait, 'position');
|
|
1299
|
+
if (position !== undefined && !HIDING_POSITIONS.includes(position)) {
|
|
1300
|
+
throw new errors_js_1.LoadError(`\`${position}\` is not a hiding position — use behind, under, on, or inside.`, trait.span);
|
|
1301
|
+
}
|
|
1302
|
+
entity.add(new world_model_1.ConcealmentTrait({
|
|
1303
|
+
positions: position
|
|
1304
|
+
? [position]
|
|
1305
|
+
: [...HIDING_POSITIONS],
|
|
1306
|
+
quality: 'good',
|
|
1307
|
+
}));
|
|
760
1308
|
break;
|
|
1309
|
+
}
|
|
761
1310
|
case 'enterable': // ADR-218 §1a (ratchet F1) — default config, preposition `in`
|
|
762
1311
|
if (!entity.has(world_model_1.TraitType.ENTERABLE))
|
|
763
1312
|
entity.add(new world_model_1.EnterableTrait());
|
|
@@ -769,6 +1318,26 @@ class ChordStory {
|
|
|
769
1318
|
case 'light-source':
|
|
770
1319
|
entity.add(new world_model_1.LightSourceTrait());
|
|
771
1320
|
break;
|
|
1321
|
+
case 'guard':
|
|
1322
|
+
case 'passive':
|
|
1323
|
+
case 'wanderer':
|
|
1324
|
+
case 'follower':
|
|
1325
|
+
case 'patrol': {
|
|
1326
|
+
// ADR-215 Q4: CORE NPC behavior vocabulary — no `use` gate.
|
|
1327
|
+
this.applyNpcAdjective(entity, irEntity, trait);
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
case 'combatant':
|
|
1331
|
+
case 'weapon': {
|
|
1332
|
+
// ADR-215 combat vocabulary — `use combat` extension adjectives.
|
|
1333
|
+
// The analyzer gated use-declaration and field names/types; this
|
|
1334
|
+
// check is the rogue-IR backstop.
|
|
1335
|
+
if (!(this.ir.uses ?? []).includes('combat')) {
|
|
1336
|
+
throw new errors_js_1.LoadError(`\`${trait.name}\` is \`combat\` extension vocabulary — add \`use combat\` to the story header.`, trait.span);
|
|
1337
|
+
}
|
|
1338
|
+
this.applyCombatAdjective(entity, irEntity, trait);
|
|
1339
|
+
break;
|
|
1340
|
+
}
|
|
772
1341
|
case 'plural': {
|
|
773
1342
|
const identity = entity.get(world_model_1.TraitType.IDENTITY);
|
|
774
1343
|
if (identity)
|
|
@@ -777,7 +1346,7 @@ class ChordStory {
|
|
|
777
1346
|
}
|
|
778
1347
|
case 'dark': {
|
|
779
1348
|
if (kind !== 'room') {
|
|
780
|
-
throw new
|
|
1349
|
+
throw new errors_js_1.LoadError(`\`dark\` applies to rooms only.`, trait.span);
|
|
781
1350
|
}
|
|
782
1351
|
break; // unconditional dark handled by the room builder
|
|
783
1352
|
}
|
|
@@ -787,13 +1356,247 @@ class ChordStory {
|
|
|
787
1356
|
// via `set`, serialized with the world — AC-6-safe).
|
|
788
1357
|
const def = this.ir.traits.find((t) => t.name === trait.name);
|
|
789
1358
|
if (def) {
|
|
790
|
-
entity.add(new ChordDataTrait(
|
|
1359
|
+
entity.add(new ChordDataTrait(state_keys_js_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
|
|
1360
|
+
break;
|
|
1361
|
+
}
|
|
1362
|
+
throw new errors_js_1.LoadError(`Trait \`${trait.name}\` is not declared (\`define trait ${trait.name}\`) and is not a v1 adjective.`, trait.span);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Per-entity NPC behavior configs stashed at composition time and
|
|
1369
|
+
* registered with the NPC service at engine-ready (world ids exist by
|
|
1370
|
+
* then, so patrol routes resolve). guard/passive use the plugin's
|
|
1371
|
+
* pre-registered behaviors; the factory-built three get per-entity ids.
|
|
1372
|
+
*/
|
|
1373
|
+
npcBehaviors = [];
|
|
1374
|
+
/**
|
|
1375
|
+
* ADR-215 Q4 core NPC routing: compose NpcTrait from a behavior
|
|
1376
|
+
* adjective — behaviorId (`guard`/`passive` built-in; factory behaviors
|
|
1377
|
+
* get `chord.npc.<id>`), movement defaults (movement behaviors default
|
|
1378
|
+
* `canMove: true`), boolean fields via NPC_FIELD_ROUTES, and room-list
|
|
1379
|
+
* fields (`allowed-rooms`/`forbidden-rooms`) filled through the shared
|
|
1380
|
+
* pending-entity-ref mechanism once every entity exists.
|
|
1381
|
+
*/
|
|
1382
|
+
applyNpcAdjective(entity, irEntity, trait) {
|
|
1383
|
+
const isFactory = trait.name === 'wanderer' || trait.name === 'follower' || trait.name === 'patrol';
|
|
1384
|
+
const data = {
|
|
1385
|
+
behaviorId: isFactory ? `chord.npc.${irEntity.id}` : trait.name,
|
|
1386
|
+
// Movement behaviors move by definition; `can-move false` overrides.
|
|
1387
|
+
canMove: isFactory,
|
|
1388
|
+
};
|
|
1389
|
+
for (const setting of trait.config) {
|
|
1390
|
+
const route = extension_registry_js_1.NPC_FIELD_ROUTES.get(setting.key);
|
|
1391
|
+
if (!route)
|
|
1392
|
+
continue; // behavior-factory params — consumed at engine-ready
|
|
1393
|
+
if (route.convert === 'boolean') {
|
|
1394
|
+
if (setting.value !== 'true' && setting.value !== 'false') {
|
|
1395
|
+
throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`${setting.key}\` takes \`true\` or \`false\`, got \`${setting.value}\`.`, trait.span);
|
|
1396
|
+
}
|
|
1397
|
+
data[route.field] = setting.value === 'true';
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
const npcTrait = new world_model_1.NpcTrait(data);
|
|
1401
|
+
entity.add(npcTrait);
|
|
1402
|
+
// Room lists resolve after every entity exists (pass-1 ordering).
|
|
1403
|
+
for (const setting of trait.config) {
|
|
1404
|
+
const route = extension_registry_js_1.NPC_FIELD_ROUTES.get(setting.key);
|
|
1405
|
+
if (route?.convert !== 'rooms')
|
|
1406
|
+
continue;
|
|
1407
|
+
const target = [];
|
|
1408
|
+
npcTrait[route.field] = target;
|
|
1409
|
+
for (const memberIrId of setting.values ?? []) {
|
|
1410
|
+
this.pendingEntityRefs.push({
|
|
1411
|
+
irRefId: memberIrId,
|
|
1412
|
+
ownerName: irEntity.name,
|
|
1413
|
+
span: trait.span,
|
|
1414
|
+
apply: (worldId) => target.push(worldId),
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (isFactory) {
|
|
1419
|
+
this.npcBehaviors.push({ irId: irEntity.id, adjective: trait.name, config: trait.config, span: trait.span });
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Build one per-entity NPC behavior instance from its stashed config
|
|
1424
|
+
* (engine-ready time — world ids exist). Chord percentages convert to
|
|
1425
|
+
* the platform's fractions here (`move-chance 50` → 0.5).
|
|
1426
|
+
*/
|
|
1427
|
+
buildNpcBehavior(pending) {
|
|
1428
|
+
const numberOf = (key) => {
|
|
1429
|
+
const setting = pending.config.find((s) => s.key === key);
|
|
1430
|
+
return setting ? Number(setting.value) : undefined;
|
|
1431
|
+
};
|
|
1432
|
+
const boolOf = (key) => {
|
|
1433
|
+
const setting = pending.config.find((s) => s.key === key);
|
|
1434
|
+
return setting ? setting.value === 'true' : undefined;
|
|
1435
|
+
};
|
|
1436
|
+
let behavior;
|
|
1437
|
+
switch (pending.adjective) {
|
|
1438
|
+
case 'wanderer': {
|
|
1439
|
+
const percent = numberOf('move-chance');
|
|
1440
|
+
behavior = (0, stdlib_2.createWandererBehavior)(percent === undefined ? {} : { moveChance: percent / 100 });
|
|
1441
|
+
break;
|
|
1442
|
+
}
|
|
1443
|
+
case 'follower':
|
|
1444
|
+
behavior = (0, stdlib_2.createFollowerBehavior)(boolOf('immediate') === undefined ? {} : { immediate: boolOf('immediate') });
|
|
1445
|
+
break;
|
|
1446
|
+
case 'patrol': {
|
|
1447
|
+
const routeSetting = pending.config.find((s) => s.key === 'route');
|
|
1448
|
+
if (!routeSetting || (routeSetting.values ?? []).length === 0) {
|
|
1449
|
+
throw new errors_js_1.LoadError(`A \`patrol\` NPC needs \`with route [ … ]\` naming its rooms.`, pending.span);
|
|
1450
|
+
}
|
|
1451
|
+
const route = (routeSetting.values ?? []).map((irId) => {
|
|
1452
|
+
const worldId = this.worldIds.get(irId);
|
|
1453
|
+
if (!worldId)
|
|
1454
|
+
throw new errors_js_1.LoadError(`A patrol route entry was never built.`, pending.span);
|
|
1455
|
+
return worldId;
|
|
1456
|
+
});
|
|
1457
|
+
behavior = (0, stdlib_2.createPatrolBehavior)({
|
|
1458
|
+
route,
|
|
1459
|
+
...(boolOf('loop') === undefined ? {} : { loop: boolOf('loop') }),
|
|
1460
|
+
...(numberOf('wait-turns') === undefined ? {} : { waitTurns: numberOf('wait-turns') }),
|
|
1461
|
+
});
|
|
1462
|
+
break;
|
|
1463
|
+
}
|
|
1464
|
+
default:
|
|
1465
|
+
throw new errors_js_1.LoadError(`Unknown NPC behavior adjective \`${pending.adjective}\`.`, pending.span);
|
|
1466
|
+
}
|
|
1467
|
+
behavior.id = `chord.npc.${pending.irId}`;
|
|
1468
|
+
return behavior;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Lower one `define machine` onto the ADR-119 shapes: platform id
|
|
1472
|
+
* `chord.machine.<slug>`, role bindings as `$<role>` entries (world
|
|
1473
|
+
* ids), action triggers on `if.action.<gerund>`, Chord conditions as
|
|
1474
|
+
* custom guards over the shared evaluator, Chord bodies as one custom
|
|
1475
|
+
* effect each through the runtime's statement executor.
|
|
1476
|
+
*/
|
|
1477
|
+
buildMachineDefinition(machine) {
|
|
1478
|
+
const bindings = {};
|
|
1479
|
+
for (const role of machine.roles) {
|
|
1480
|
+
const worldId = this.worldIds.get(role.entity);
|
|
1481
|
+
if (!worldId) {
|
|
1482
|
+
throw new errors_js_1.LoadError(`Machine \`${machine.name}\`: role \`${role.name}\`'s entity was never built.`, machine.span);
|
|
1483
|
+
}
|
|
1484
|
+
bindings[`$${role.name}`] = worldId;
|
|
1485
|
+
}
|
|
1486
|
+
const chordGuard = (condition) => ({
|
|
1487
|
+
type: 'custom',
|
|
1488
|
+
evaluate: (world) => this.evaluator.evalCondition(condition, { world: world }),
|
|
1489
|
+
});
|
|
1490
|
+
const chordEffect = (statements) => ({
|
|
1491
|
+
type: 'custom',
|
|
1492
|
+
execute: (world) => ({
|
|
1493
|
+
events: this.runtime
|
|
1494
|
+
.execMachineBody(statements, world)
|
|
1495
|
+
.map((e) => ({ type: e.type, data: e.data, entities: e.entities })),
|
|
1496
|
+
}),
|
|
1497
|
+
});
|
|
1498
|
+
const states = {};
|
|
1499
|
+
for (const state of machine.states) {
|
|
1500
|
+
const transitions = state.transitions.map((t) => {
|
|
1501
|
+
let trigger;
|
|
1502
|
+
switch (t.trigger.kind) {
|
|
1503
|
+
case 'action': {
|
|
1504
|
+
let targetEntity;
|
|
1505
|
+
if (t.trigger.target) {
|
|
1506
|
+
targetEntity = t.trigger.target.startsWith('$')
|
|
1507
|
+
? t.trigger.target
|
|
1508
|
+
: this.worldIds.get(t.trigger.target);
|
|
1509
|
+
if (targetEntity === undefined) {
|
|
1510
|
+
throw new errors_js_1.LoadError(`Machine \`${machine.name}\`: a trigger target was never built.`, t.span);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
trigger = {
|
|
1514
|
+
type: 'action',
|
|
1515
|
+
actionId: `if.action.${t.trigger.action}`,
|
|
1516
|
+
...(targetEntity !== undefined ? { targetEntity } : {}),
|
|
1517
|
+
};
|
|
791
1518
|
break;
|
|
792
1519
|
}
|
|
793
|
-
|
|
1520
|
+
case 'event':
|
|
1521
|
+
// ADR-256: translate the dotless Chord id to the platform runtime
|
|
1522
|
+
// type the machine fires on (media.* → dotted; author events pass
|
|
1523
|
+
// through), matching the emit seam.
|
|
1524
|
+
trigger = { type: 'event', eventId: (0, event_id_map_js_1.translateEventId)(t.trigger.event) };
|
|
1525
|
+
break;
|
|
1526
|
+
case 'condition':
|
|
1527
|
+
trigger = { type: 'condition', condition: chordGuard(t.trigger.condition) };
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
target: t.target,
|
|
1532
|
+
trigger,
|
|
1533
|
+
...(t.condition ? { guard: chordGuard(t.condition) } : {}),
|
|
1534
|
+
};
|
|
1535
|
+
});
|
|
1536
|
+
states[state.name] = {
|
|
1537
|
+
...(state.terminal ? { terminal: true } : {}),
|
|
1538
|
+
...(state.onEnter.length > 0 ? { onEnter: [chordEffect(state.onEnter)] } : {}),
|
|
1539
|
+
...(state.onExit.length > 0 ? { onExit: [chordEffect(state.onExit)] } : {}),
|
|
1540
|
+
...(transitions.length > 0 ? { transitions } : {}),
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
return {
|
|
1544
|
+
definition: {
|
|
1545
|
+
id: `chord.machine.${machine.name.replace(/\s+/g, '-')}`,
|
|
1546
|
+
initialState: machine.initialState,
|
|
1547
|
+
states,
|
|
1548
|
+
},
|
|
1549
|
+
bindings,
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* ADR-215 combat routing: compose `combatant` (CombatantTrait + the
|
|
1554
|
+
* REQUIRED HealthTrait per ADR-226 — health/max-health route THERE) or
|
|
1555
|
+
* `weapon` (WeaponTrait) from a composition's `with`-fields, via the
|
|
1556
|
+
* exported COMBAT_FIELD_ROUTES table the manifest-conformance test pins.
|
|
1557
|
+
*/
|
|
1558
|
+
applyCombatAdjective(entity, irEntity, trait) {
|
|
1559
|
+
const values = {
|
|
1560
|
+
combatant: {},
|
|
1561
|
+
health: {},
|
|
1562
|
+
weapon: {},
|
|
1563
|
+
};
|
|
1564
|
+
for (const setting of trait.config) {
|
|
1565
|
+
const route = extension_registry_js_1.COMBAT_FIELD_ROUTES.get(setting.key);
|
|
1566
|
+
if (!route) {
|
|
1567
|
+
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);
|
|
1568
|
+
}
|
|
1569
|
+
if (route.convert === 'number') {
|
|
1570
|
+
const parsed = Number(setting.value);
|
|
1571
|
+
if (Number.isNaN(parsed)) {
|
|
1572
|
+
throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`${setting.key}\` needs a number, got \`${setting.value}\`.`, trait.span);
|
|
794
1573
|
}
|
|
1574
|
+
values[route.trait][route.field] = parsed;
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
if (setting.value !== 'true' && setting.value !== 'false') {
|
|
1578
|
+
throw new errors_js_1.LoadError(`\`${irEntity.name}\`: \`${setting.key}\` takes \`true\` or \`false\`, got \`${setting.value}\`.`, trait.span);
|
|
1579
|
+
}
|
|
1580
|
+
values[route.trait][route.field] = setting.value === 'true';
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (trait.name === 'weapon') {
|
|
1584
|
+
entity.add(new world_model_1.WeaponTrait(values.weapon));
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
// CombatantTrait REQUIRES a HealthTrait (ADR-226 §2) — auto-attach,
|
|
1588
|
+
// seeded from the health/max-health fields (defaults when omitted).
|
|
1589
|
+
if (!entity.has(world_model_1.TraitType.HEALTH)) {
|
|
1590
|
+
entity.add(new world_model_1.HealthTrait(values.health));
|
|
1591
|
+
}
|
|
1592
|
+
else if (Object.keys(values.health).length > 0) {
|
|
1593
|
+
const health = entity.get(world_model_1.TraitType.HEALTH);
|
|
1594
|
+
Object.assign(health, values.health);
|
|
1595
|
+
if (values.health.health !== undefined && values.health.maxHealth === undefined) {
|
|
1596
|
+
health.maxHealth = Math.max(health.maxHealth, health.health);
|
|
795
1597
|
}
|
|
796
1598
|
}
|
|
1599
|
+
entity.add(new world_model_1.CombatantTrait(values.combatant));
|
|
797
1600
|
}
|
|
798
1601
|
/**
|
|
799
1602
|
* Initial values for a `define trait` instance: declared `starts`
|
|
@@ -811,7 +1614,7 @@ class ChordStory {
|
|
|
811
1614
|
const lower = setting.value.toLowerCase();
|
|
812
1615
|
const target = this.ir.entities.find((e) => e.name.toLowerCase() === lower || e.aka.includes(lower));
|
|
813
1616
|
if (!target) {
|
|
814
|
-
throw new
|
|
1617
|
+
throw new errors_js_1.LoadError(`\`${setting.value}\` (config \`${setting.key}\`) names no entity.`, comp.span);
|
|
815
1618
|
}
|
|
816
1619
|
values[setting.key] = target.id;
|
|
817
1620
|
}
|
|
@@ -838,7 +1641,7 @@ class ChordStory {
|
|
|
838
1641
|
requireWorldId(irId, at) {
|
|
839
1642
|
const id = this.worldIds.get(irId);
|
|
840
1643
|
if (!id) {
|
|
841
|
-
throw new
|
|
1644
|
+
throw new errors_js_1.LoadError(`Entity \`${irId}\` is referenced before it exists in the world.`, at?.span);
|
|
842
1645
|
}
|
|
843
1646
|
return id;
|
|
844
1647
|
}
|
|
@@ -890,9 +1693,9 @@ class ChordStory {
|
|
|
890
1693
|
if (phrase.verbatim) {
|
|
891
1694
|
// The analyzer already errors here (analysis.verbatim-marker);
|
|
892
1695
|
// this is the loader's defensive half of the same contract.
|
|
893
|
-
throw new
|
|
1696
|
+
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
1697
|
}
|
|
895
|
-
const variantTexts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0,
|
|
1698
|
+
const variantTexts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_js_1.withLineBreaks)(v.text)));
|
|
896
1699
|
let mentions;
|
|
897
1700
|
if (phrase.condition) {
|
|
898
1701
|
const subject = presenceSubject(phrase.condition, irEntity.id);
|
|
@@ -904,7 +1707,7 @@ class ChordStory {
|
|
|
904
1707
|
gates.push(() => (0, stdlib_1.registerSnippetGate)(entity.id, marker, () => this.evaluator.evalCondition(condition, { world })));
|
|
905
1708
|
}
|
|
906
1709
|
}
|
|
907
|
-
const selector = phrase.strategy ?
|
|
1710
|
+
const selector = phrase.strategy ? runtime_js_1.STRATEGY_SELECTOR[phrase.strategy] : undefined;
|
|
908
1711
|
let entry;
|
|
909
1712
|
if (!selector && variantTexts.length === 1) {
|
|
910
1713
|
// Single-variant plain phrase → plain string entry, never a Choice.
|
|
@@ -999,7 +1802,7 @@ class ChordStory {
|
|
|
999
1802
|
break;
|
|
1000
1803
|
if (!phrase.condition)
|
|
1001
1804
|
continue; // analyzer already errored (detail-unconditional)
|
|
1002
|
-
const text = (0,
|
|
1805
|
+
const text = (0, text_js_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
|
|
1003
1806
|
const shape = detailTraitShape(phrase.condition, irEntity.id);
|
|
1004
1807
|
if (shape === 'on' && entity.has(world_model_1.TraitType.SWITCHABLE)) {
|
|
1005
1808
|
entity.get(world_model_1.TraitType.SWITCHABLE).detailWhenOn = text;
|
|
@@ -1033,9 +1836,31 @@ class ChordStory {
|
|
|
1033
1836
|
phraseText(key) {
|
|
1034
1837
|
const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
|
|
1035
1838
|
if (!phrase) {
|
|
1036
|
-
throw new
|
|
1839
|
+
throw new errors_js_1.LoadError(`Phrase \`${key}\` is missing from the IR — the compiler gate should have caught this.`);
|
|
1840
|
+
}
|
|
1841
|
+
return (0, text_js_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Evaluate a channel's `return` construct (ADR-253 D1) against an event's
|
|
1845
|
+
* payload to produce the channel's value:
|
|
1846
|
+
* - `field` → the raw field value (may be a non-string);
|
|
1847
|
+
* - `text` → the template with each `(slot)` replaced by its event field;
|
|
1848
|
+
* - `phrase` → the named phrase's first-variant text, its `(slot)`s likewise
|
|
1849
|
+
* filled from the event payload (locale-aware via `phraseText`).
|
|
1850
|
+
*/
|
|
1851
|
+
evaluateChannelReturn(returns, data) {
|
|
1852
|
+
const fill = (template) => template.replace(/\(\s*([^)]+?)\s*\)/g, (_m, name) => {
|
|
1853
|
+
const v = data[name];
|
|
1854
|
+
return v === undefined || v === null ? '' : String(v);
|
|
1855
|
+
});
|
|
1856
|
+
switch (returns.kind) {
|
|
1857
|
+
case 'field':
|
|
1858
|
+
return data[returns.field];
|
|
1859
|
+
case 'text':
|
|
1860
|
+
return fill(returns.text);
|
|
1861
|
+
case 'phrase':
|
|
1862
|
+
return fill(this.phraseText(returns.phrase));
|
|
1037
1863
|
}
|
|
1038
|
-
return (0, text_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
|
|
1039
1864
|
}
|
|
1040
1865
|
}
|
|
1041
1866
|
exports.ChordStory = ChordStory;
|
|
@@ -1080,11 +1905,26 @@ function presenceSubject(cond, roomIrId) {
|
|
|
1080
1905
|
}
|
|
1081
1906
|
return null;
|
|
1082
1907
|
}
|
|
1908
|
+
/**
|
|
1909
|
+
* ADR-231 D5a platform mapping: `starts <state>` word → the paired trait's
|
|
1910
|
+
* initial-value field and the boolean it sets. The language-side pairing
|
|
1911
|
+
* table (which trait must be composed) is @sharpee/chord's
|
|
1912
|
+
* STARTS_STATE_PAIRINGS; this is the loader's platform half — future
|
|
1913
|
+
* stateful traits extend both tables, not the code.
|
|
1914
|
+
*/
|
|
1915
|
+
const STARTS_STATE_TRAIT_FIELDS = new Map([
|
|
1916
|
+
['locked', { traitType: world_model_1.TraitType.LOCKABLE, traitName: 'lockable', field: 'isLocked', value: true }],
|
|
1917
|
+
['unlocked', { traitType: world_model_1.TraitType.LOCKABLE, traitName: 'lockable', field: 'isLocked', value: false }],
|
|
1918
|
+
['closed', { traitType: world_model_1.TraitType.OPENABLE, traitName: 'openable', field: 'isOpen', value: false }],
|
|
1919
|
+
['open', { traitType: world_model_1.TraitType.OPENABLE, traitName: 'openable', field: 'isOpen', value: true }],
|
|
1920
|
+
['off', { traitType: world_model_1.TraitType.SWITCHABLE, traitName: 'switchable', field: 'isOn', value: false }],
|
|
1921
|
+
['on', { traitType: world_model_1.TraitType.SWITCHABLE, traitName: 'switchable', field: 'isOn', value: true }],
|
|
1922
|
+
]);
|
|
1083
1923
|
/** Chord direction word → world-model DirectionType. */
|
|
1084
1924
|
function toDirection(word, at) {
|
|
1085
1925
|
const dir = world_model_1.Direction[word.toUpperCase()];
|
|
1086
1926
|
if (!dir)
|
|
1087
|
-
throw new
|
|
1927
|
+
throw new errors_js_1.LoadError(`Unknown direction \`${word}\`.`, at?.span);
|
|
1088
1928
|
return dir;
|
|
1089
1929
|
}
|
|
1090
1930
|
/** `with capacity N` on a supporter kind. */
|
|
@@ -1095,6 +1935,14 @@ function supporterCapacity(kind) {
|
|
|
1095
1935
|
function configValue(comp, key) {
|
|
1096
1936
|
return comp.config.find((c) => c.key === key)?.value;
|
|
1097
1937
|
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Ratchet R3 (ADR-234 D6): the adjective's single-entity `with` value —
|
|
1940
|
+
* keyless (`lockable with the iron key`). The parser stores it under the
|
|
1941
|
+
* empty key with valueKind 'name'.
|
|
1942
|
+
*/
|
|
1943
|
+
function entityConfigValue(comp) {
|
|
1944
|
+
return comp.config.find((c) => c.key === '' && c.valueKind === 'name')?.value;
|
|
1945
|
+
}
|
|
1098
1946
|
/**
|
|
1099
1947
|
* The Language Provider template for an IR phrase. Single-variant phrases
|
|
1100
1948
|
* register their text (with `{br}` mapped to hard line breaks); verbatim
|
|
@@ -1105,7 +1953,7 @@ function templateFor(phrase) {
|
|
|
1105
1953
|
if (phrase.verbatim)
|
|
1106
1954
|
return '{verbatim:text}';
|
|
1107
1955
|
if (phrase.strategy === null && phrase.variants.length === 1)
|
|
1108
|
-
return (0,
|
|
1956
|
+
return (0, text_js_1.withLineBreaks)(phrase.variants[0].text);
|
|
1109
1957
|
return '{variants}';
|
|
1110
1958
|
}
|
|
1111
1959
|
/**
|
|
@@ -1122,7 +1970,7 @@ function toVocabularyVerb(verb) {
|
|
|
1122
1970
|
const KNOWN = { 'put on': 'PUT_ON' };
|
|
1123
1971
|
const actionId = KNOWN[`${base} ${preposition ?? ''}`.trim()];
|
|
1124
1972
|
if (!actionId || slots !== 2) {
|
|
1125
|
-
throw new
|
|
1973
|
+
throw new errors_js_1.LoadError(`\`define verb ${verb.verbs.join(' or ')}\` maps to \`${words.join(' ')}\`, which the Phase A loader cannot register.`);
|
|
1126
1974
|
}
|
|
1127
1975
|
return {
|
|
1128
1976
|
actionId,
|