@sharpee/story-loader 3.0.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +59 -23
- 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 +54 -0
- package/extension-registry.d.ts.map +1 -0
- package/extension-registry.js +70 -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 +132 -5
- package/loader.d.ts.map +1 -1
- package/loader.js +1008 -109
- 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 +836 -0
- package/message-alias-map.js.map +1 -0
- package/package.json +11 -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 +158 -19
- package/runtime.d.ts.map +1 -1
- package/runtime.js +846 -169
- package/runtime.js.map +1 -1
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
|
|
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
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
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,14 +81,40 @@ 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
|
|
80
|
-
|
|
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. */
|
|
84
89
|
evaluator;
|
|
85
90
|
/** IR entity ID → world entity ID (populated by initializeWorld/createPlayer). */
|
|
86
91
|
worldIds = new Map();
|
|
92
|
+
/**
|
|
93
|
+
* Trait-config entity references awaiting world-id resolution (ADR-230
|
|
94
|
+
* D3c / Phase 9a): `cuttable with tool the knife`, `lockable with key the
|
|
95
|
+
* brass key` may name entities built AFTER their owner, so trait fields
|
|
96
|
+
* are stamped once every entity exists — config values are NEVER left as
|
|
97
|
+
* raw display-name strings.
|
|
98
|
+
*/
|
|
99
|
+
pendingEntityRefs = [];
|
|
100
|
+
/** Resolve a trait-config entity NAME to an IR entity and queue the
|
|
101
|
+
* world-id application (throws LoadError when nothing matches). */
|
|
102
|
+
entityRefFor(name, configKey, owner, span, apply) {
|
|
103
|
+
const lower = name.toLowerCase();
|
|
104
|
+
const target = this.ir.entities.find((e) => e.name.toLowerCase() === lower || e.aka.includes(lower));
|
|
105
|
+
if (!target) {
|
|
106
|
+
throw new errors_js_1.LoadError(`\`${name}\` (config \`${configKey}\`) names no entity.`, span);
|
|
107
|
+
}
|
|
108
|
+
return { irRefId: target.id, ownerName: owner.name, span, apply };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Deadly exits (ADR-227): world room id → DIRECTION → derived
|
|
112
|
+
* cause/messageId (both the phrase key). Lowered in `onEngineReady` to ONE
|
|
113
|
+
* pre-validate command transformer redirecting to the platform's generic
|
|
114
|
+
* deadly-death action — a deadly exit need not exist in the room graph, so
|
|
115
|
+
* no destination-resolved interceptor could ever fire.
|
|
116
|
+
*/
|
|
117
|
+
deadlyExits = new Map();
|
|
87
118
|
/** World entity ID → IR entity ID (state lookups in the evaluator). */
|
|
88
119
|
irIds = new Map();
|
|
89
120
|
world = null;
|
|
@@ -94,7 +125,7 @@ class ChordStory {
|
|
|
94
125
|
constructor(ir, options) {
|
|
95
126
|
this.ir = ir;
|
|
96
127
|
if (ir.format !== chord_1.IR_FORMAT) {
|
|
97
|
-
throw new
|
|
128
|
+
throw new errors_js_1.LoadError(`Unsupported IR format \`${String(ir.format)}\` — this loader reads \`${chord_1.IR_FORMAT}\`.`);
|
|
98
129
|
}
|
|
99
130
|
this.config = {
|
|
100
131
|
id: ir.meta.fields.id ?? ir.meta.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
@@ -104,8 +135,8 @@ class ChordStory {
|
|
|
104
135
|
description: ir.meta.fields.blurb,
|
|
105
136
|
};
|
|
106
137
|
this.bindHatches(options);
|
|
107
|
-
this.evaluator = new
|
|
108
|
-
this.runtime = new
|
|
138
|
+
this.evaluator = new evaluator_js_1.Evaluator(ir, this, options.seed);
|
|
139
|
+
this.runtime = new runtime_js_1.ChordRuntime(ir, this, this.evaluator);
|
|
109
140
|
}
|
|
110
141
|
/** The world entity ID for an IR entity ID (after initializeWorld). */
|
|
111
142
|
entityId(irId) {
|
|
@@ -125,29 +156,31 @@ class ChordStory {
|
|
|
125
156
|
// any module — no author-supplied code is read, called, or bound.
|
|
126
157
|
if ((options.profile ?? 'devkit') === 'pure-ir' && this.ir.hatches.length > 0) {
|
|
127
158
|
const names = this.ir.hatches.map((h) => `\`${h.name}\` (${h.modulePath})`).join(', ');
|
|
128
|
-
throw new
|
|
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.`);
|
|
129
160
|
}
|
|
130
161
|
for (const hatch of this.ir.hatches) {
|
|
131
162
|
const module = options.hatchModules?.[hatch.modulePath];
|
|
132
163
|
if (!module) {
|
|
133
|
-
throw new
|
|
164
|
+
throw new errors_js_1.LoadError(`Hatch module \`${hatch.modulePath}\` was not provided to the loader.`, hatch.span);
|
|
134
165
|
}
|
|
135
|
-
|
|
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];
|
|
136
169
|
// Bind-time `'chord.'` lint (design.md §5.6, best-effort backstop —
|
|
137
170
|
// the staging facade is the wall): the loader-private state namespace
|
|
138
171
|
// is off-limits to hatches; a quoted literal fails the bind atomically,
|
|
139
172
|
// like a missing export. The devkit source lint is the authoritative
|
|
140
173
|
// layer (this one can miss minified code and can trip on a quoted
|
|
141
174
|
// literal inside a compiled-in comment — reword the comment).
|
|
142
|
-
const chordLiteral = (0,
|
|
175
|
+
const chordLiteral = (0, hatch_context_js_1.findChordLiteral)(bound);
|
|
143
176
|
if (chordLiteral !== null) {
|
|
144
|
-
throw new
|
|
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);
|
|
145
178
|
}
|
|
146
179
|
const kind = hatch.hatchKind ?? 'text';
|
|
147
180
|
switch (kind) {
|
|
148
181
|
case 'text': {
|
|
149
182
|
if (typeof bound !== 'function') {
|
|
150
|
-
throw new
|
|
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);
|
|
151
184
|
}
|
|
152
185
|
this.producers.set(hatch.name, bound);
|
|
153
186
|
break;
|
|
@@ -156,50 +189,153 @@ class ChordStory {
|
|
|
156
189
|
// Interface Contract 3: the export IS a four-phase Action.
|
|
157
190
|
const action = bound;
|
|
158
191
|
if (!action || typeof action !== 'object' || typeof action.validate !== 'function' || typeof action.execute !== 'function') {
|
|
159
|
-
throw new
|
|
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);
|
|
160
193
|
}
|
|
161
194
|
this.boundActions.set(hatch.name, bound);
|
|
162
195
|
break;
|
|
163
196
|
}
|
|
164
|
-
case '
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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);
|
|
168
202
|
}
|
|
169
|
-
this.
|
|
203
|
+
this.boundChains.set(hatch.name, bound);
|
|
170
204
|
break;
|
|
171
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.
|
|
172
209
|
}
|
|
173
210
|
}
|
|
174
211
|
}
|
|
175
212
|
// ------------------------------------------------------------ lifecycle
|
|
176
213
|
initializeWorld(world) {
|
|
177
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
|
+
}
|
|
178
240
|
const built = [];
|
|
179
|
-
// Pass
|
|
241
|
+
// Pass 0 — regions, 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.
|
|
180
248
|
for (const irEntity of this.ir.entities) {
|
|
181
|
-
if (irEntity.isPlayer)
|
|
249
|
+
if (irEntity.isPlayer || this.worldIds.has(irEntity.id))
|
|
182
250
|
continue;
|
|
183
251
|
built.push({ ir: irEntity, entity: this.buildEntity(world, irEntity) });
|
|
184
252
|
}
|
|
185
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);
|
|
186
260
|
for (const { ir: irEntity, entity } of built) {
|
|
187
261
|
if (irEntity.placement && irEntity.placement.relation !== 'starts-in') {
|
|
188
|
-
|
|
262
|
+
author.moveEntity(entity.id, this.requireWorldId(irEntity.placement.place, irEntity));
|
|
263
|
+
}
|
|
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
|
+
}
|
|
189
273
|
}
|
|
190
274
|
for (const exit of irEntity.exits) {
|
|
191
|
-
|
|
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;
|
|
307
|
+
}
|
|
308
|
+
door?.add(new world_model_1.DoorTrait({ room1: entity.id, room2: toId }));
|
|
309
|
+
world.connectRooms(entity.id, toId, direction, doorId);
|
|
192
310
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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).
|
|
316
|
+
// ADR-227: `deadly: <phrase>` — the no-escape room marker lowers to
|
|
317
|
+
// DeadlyRoomTrait (safeVerbs default look/examine); the ENGINE
|
|
318
|
+
// auto-registers the deadly-room transformer, so no runtime code here.
|
|
319
|
+
// Cause and messageId both derive from the phrase key.
|
|
320
|
+
if (irEntity.deadly) {
|
|
321
|
+
entity.add(new world_model_1.DeadlyRoomTrait({
|
|
322
|
+
cause: irEntity.deadly.phraseKey,
|
|
323
|
+
messageId: irEntity.deadly.phraseKey,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
// ADR-227: `<direction> is deadly: <phrase>` — collected here, lowered
|
|
327
|
+
// to one command transformer in onEngineReady.
|
|
328
|
+
for (const deadly of irEntity.deadlyExits) {
|
|
329
|
+
if (deadly.condition !== null) {
|
|
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);
|
|
199
331
|
}
|
|
332
|
+
const direction = toDirection(deadly.direction, irEntity);
|
|
333
|
+
const byRoom = this.deadlyExits.get(entity.id) ?? new Map();
|
|
334
|
+
byRoom.set(String(direction).toUpperCase(), { cause: deadly.phraseKey, messageId: deadly.phraseKey });
|
|
335
|
+
this.deadlyExits.set(entity.id, byRoom);
|
|
200
336
|
}
|
|
201
337
|
if (irEntity.states.length > 0) {
|
|
202
|
-
world.setStateValue(
|
|
338
|
+
world.setStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irEntity.id, irEntity.states[0]);
|
|
203
339
|
}
|
|
204
340
|
// Z2 (ADR-211): compile `{key}` description markers onto ADR-209
|
|
205
341
|
// snippet storage — atomically per room, before the engine's
|
|
@@ -208,9 +344,17 @@ class ChordStory {
|
|
|
208
344
|
this.compileRoomSnippets(world, irEntity, entity);
|
|
209
345
|
}
|
|
210
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
|
+
}
|
|
211
355
|
// The story object starts in its first declared phase (ratchet D2).
|
|
212
356
|
if (this.ir.story.states.length > 0) {
|
|
213
|
-
world.setStateValue(
|
|
357
|
+
world.setStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY, this.ir.story.states[0]);
|
|
214
358
|
}
|
|
215
359
|
// Declared scores set the ceiling (dedup-by-identity makes the sum
|
|
216
360
|
// exact — ADR-129).
|
|
@@ -223,9 +367,15 @@ class ChordStory {
|
|
|
223
367
|
// Z3b: gated `detail` blocks — shipped trait fields where the condition
|
|
224
368
|
// matches them, a loader-owned state-clause provider for everything else.
|
|
225
369
|
this.compileDetailChannels(world);
|
|
370
|
+
// ADR-230 D3c / Phase 9a: stamp trait-config entity references (tools,
|
|
371
|
+
// keys) now that every entity exists (forward references resolve here).
|
|
372
|
+
this.resolvePendingEntityRefs();
|
|
226
373
|
// Bind the turn-by-turn runtime: rules, on-clause interceptors,
|
|
227
374
|
// derived-property chains (all per-world, keyed — ADR-207/208).
|
|
228
375
|
this.runtime.bind(world);
|
|
376
|
+
// ADR-230 D3c (PIN 3, dual-surface re-pin): an unimplemented cuttable
|
|
377
|
+
// is an authoring error at load, never a silent runtime no-op.
|
|
378
|
+
this.checkCuttableImplementations(world);
|
|
229
379
|
this.worldBuilt = true;
|
|
230
380
|
// Engine order (GameEngine.setStory): createPlayer ran before world
|
|
231
381
|
// content existed — the player can be placed and equipped only now.
|
|
@@ -276,6 +426,21 @@ class ChordStory {
|
|
|
276
426
|
if (startIr) {
|
|
277
427
|
world.moveEntity(player.id, this.requireWorldId(startIr, irPlayer ?? undefined));
|
|
278
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
|
+
}
|
|
440
|
+
// Carried items: into inventory (ADR-230 Phase 6 — `carries the X`).
|
|
441
|
+
for (const carriedIrId of irPlayer?.carries ?? []) {
|
|
442
|
+
world.moveEntity(this.requireWorldId(carriedIrId, irPlayer ?? undefined), player.id);
|
|
443
|
+
}
|
|
279
444
|
// Worn items: into inventory, marked worn.
|
|
280
445
|
for (const wornIrId of irPlayer?.wears ?? []) {
|
|
281
446
|
const wornId = this.requireWorldId(wornIrId, irPlayer ?? undefined);
|
|
@@ -283,14 +448,13 @@ class ChordStory {
|
|
|
283
448
|
const worn = world.getEntity(wornId);
|
|
284
449
|
const wearable = worn?.get(world_model_1.TraitType.WEARABLE);
|
|
285
450
|
if (!wearable) {
|
|
286
|
-
throw new
|
|
451
|
+
throw new errors_js_1.LoadError(`\`${wornIrId}\` is worn by the player but is not wearable.`, irPlayer?.span);
|
|
287
452
|
}
|
|
288
453
|
wearable.worn = true;
|
|
289
454
|
wearable.wornBy = player.id;
|
|
290
455
|
}
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
this.runtime.recomputeDerived(world);
|
|
456
|
+
// ADR-240: no initial derived-property evaluation — derived state is
|
|
457
|
+
// registered evaluators, consulted live at every read.
|
|
294
458
|
}
|
|
295
459
|
extendLanguage(language) {
|
|
296
460
|
// `addMessage` is the concrete providers' registration surface
|
|
@@ -298,12 +462,23 @@ class ChordStory {
|
|
|
298
462
|
// structurally so the loader stays locale-neutral.
|
|
299
463
|
const registry = language;
|
|
300
464
|
if (typeof registry.addMessage !== 'function') {
|
|
301
|
-
throw new
|
|
465
|
+
throw new errors_js_1.LoadError('The language provider does not support message registration (addMessage).');
|
|
302
466
|
}
|
|
303
467
|
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
304
468
|
for (const [key, phrase] of Object.entries(table)) {
|
|
305
469
|
registry.addMessage(key, templateFor(phrase));
|
|
306
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
|
+
}
|
|
307
482
|
}
|
|
308
483
|
getCustomVocabulary() {
|
|
309
484
|
return { verbs: this.ir.verbs.map((v) => toVocabularyVerb(v)) };
|
|
@@ -322,6 +497,37 @@ class ChordStory {
|
|
|
322
497
|
* state is world state — no runner-state plumbing (design.md §6).
|
|
323
498
|
*/
|
|
324
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
|
+
}
|
|
325
531
|
const daemons = this.runtime.buildSchedulerDaemons();
|
|
326
532
|
if (daemons.length > 0) {
|
|
327
533
|
const plugin = new plugin_scheduler_1.SchedulerPlugin();
|
|
@@ -330,11 +536,114 @@ class ChordStory {
|
|
|
330
536
|
for (const daemon of daemons)
|
|
331
537
|
scheduler.registerDaemon(daemon);
|
|
332
538
|
}
|
|
539
|
+
// ADR-227: `<direction> is deadly:` — one pre-validate transformer over
|
|
540
|
+
// the collected deadly-exit map, redirecting a matching going command to
|
|
541
|
+
// the platform's generic extras-driven deadly-death action (the same
|
|
542
|
+
// seam stdlib's own deadly-room transformer uses).
|
|
543
|
+
if (this.deadlyExits.size > 0 && engine.registerParsedCommandTransformer) {
|
|
544
|
+
engine.registerParsedCommandTransformer(this.buildDeadlyExitTransformer());
|
|
545
|
+
}
|
|
333
546
|
// Z3 (ADR-212 §5): every `phrase present:` block compiles to ONE
|
|
334
547
|
// declarative slot entry — no synthesized closures; the platform's
|
|
335
548
|
// built-in contributor evaluates them.
|
|
336
549
|
this.registerPresentEntries(engine);
|
|
337
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
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The deadly-exit command transformer (ADR-227). Redirects `going
|
|
605
|
+
* <deadly-direction>` from a room with declared deadly exits to
|
|
606
|
+
* `DEADLY_ROOM_DEATH_ACTION_ID`, threading the phrase-derived
|
|
607
|
+
* cause/messageId through extras. Pass-through otherwise.
|
|
608
|
+
*/
|
|
609
|
+
buildDeadlyExitTransformer() {
|
|
610
|
+
const deadlyExits = this.deadlyExits;
|
|
611
|
+
// Single-letter direction abbreviations some parsers surface in extras.
|
|
612
|
+
const ABBREV = {
|
|
613
|
+
N: 'NORTH', S: 'SOUTH', E: 'EAST', W: 'WEST',
|
|
614
|
+
NE: 'NORTHEAST', NW: 'NORTHWEST', SE: 'SOUTHEAST', SW: 'SOUTHWEST',
|
|
615
|
+
U: 'UP', D: 'DOWN',
|
|
616
|
+
};
|
|
617
|
+
return (parsed, world) => {
|
|
618
|
+
const actionId = parsed.action?.toLowerCase() ?? '';
|
|
619
|
+
if (actionId !== 'if.action.going' && actionId !== 'going')
|
|
620
|
+
return parsed;
|
|
621
|
+
const player = world.getPlayer();
|
|
622
|
+
if (!player)
|
|
623
|
+
return parsed;
|
|
624
|
+
const roomId = world.getLocation(player.id);
|
|
625
|
+
if (!roomId)
|
|
626
|
+
return parsed;
|
|
627
|
+
const byRoom = deadlyExits.get(roomId);
|
|
628
|
+
if (!byRoom)
|
|
629
|
+
return parsed;
|
|
630
|
+
const raw = String(parsed.extras?.direction ?? '').toUpperCase();
|
|
631
|
+
const direction = ABBREV[raw] ?? raw;
|
|
632
|
+
const hit = byRoom.get(direction);
|
|
633
|
+
if (!hit)
|
|
634
|
+
return parsed;
|
|
635
|
+
return {
|
|
636
|
+
...parsed,
|
|
637
|
+
action: stdlib_1.DEADLY_ROOM_DEATH_ACTION_ID,
|
|
638
|
+
extras: {
|
|
639
|
+
...parsed.extras,
|
|
640
|
+
[stdlib_1.DEADLY_ROOM_CAUSE_KEY]: hit.cause,
|
|
641
|
+
[stdlib_1.DEADLY_ROOM_MESSAGE_KEY]: hit.messageId,
|
|
642
|
+
originalAction: parsed.action,
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
};
|
|
646
|
+
}
|
|
338
647
|
/**
|
|
339
648
|
* Z3 `present` channel → ADR-212 slot entries. One `registerSlotEntry`
|
|
340
649
|
* call per authoring entity: `slotKey: 'here'`, owner = the entity,
|
|
@@ -360,13 +669,13 @@ class ChordStory {
|
|
|
360
669
|
const ownerWorldId = this.worldIds.get(irEntity.id);
|
|
361
670
|
if (!ownerWorldId)
|
|
362
671
|
continue;
|
|
363
|
-
const texts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0,
|
|
672
|
+
const texts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_js_1.withLineBreaks)(v.text)));
|
|
364
673
|
const content = texts.length === 1 && !phrase.strategy
|
|
365
674
|
? { kind: 'literal', text: texts[0] }
|
|
366
675
|
: {
|
|
367
676
|
kind: 'choice',
|
|
368
677
|
alternatives: texts.map((text) => ({ kind: 'literal', text })),
|
|
369
|
-
selector:
|
|
678
|
+
selector: runtime_js_1.STRATEGY_SELECTOR[phrase.strategy ?? 'cycling'],
|
|
370
679
|
// ADR-212 §4 caller contract: entityId = owner, messageKey =
|
|
371
680
|
// counterKey — the platform warns on a mismatch.
|
|
372
681
|
entityId: ownerWorldId,
|
|
@@ -404,6 +713,14 @@ class ChordStory {
|
|
|
404
713
|
extendParser(parser) {
|
|
405
714
|
const grammar = parser.getStoryGrammar();
|
|
406
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();
|
|
407
724
|
for (const pattern of action.patterns) {
|
|
408
725
|
if (pattern.cardinality)
|
|
409
726
|
continue; // `→ each …` expansion is engine-owned (Phase C)
|
|
@@ -411,6 +728,18 @@ class ChordStory {
|
|
|
411
728
|
.map((part) => (part.kind === 'slot' ? `:${part.word}` : part.word))
|
|
412
729
|
.join(' ');
|
|
413
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();
|
|
414
743
|
}
|
|
415
744
|
}
|
|
416
745
|
}
|
|
@@ -433,86 +762,245 @@ class ChordStory {
|
|
|
433
762
|
};
|
|
434
763
|
}
|
|
435
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
|
+
}
|
|
436
803
|
buildEntity(world, irEntity) {
|
|
437
804
|
if (irEntity.kinds.length > 1) {
|
|
438
|
-
throw new
|
|
805
|
+
throw new errors_js_1.LoadError(`\`${irEntity.name}\` declares more than one kind noun.`, irEntity.span);
|
|
439
806
|
}
|
|
440
807
|
const kind = irEntity.kinds[0]?.name ?? null;
|
|
441
808
|
const description = irEntity.descriptionKey ? this.phraseText(irEntity.descriptionKey) : undefined;
|
|
442
|
-
|
|
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;
|
|
443
812
|
let entity;
|
|
444
813
|
switch (kind) {
|
|
445
814
|
case 'room': {
|
|
446
|
-
const builder = h.room(irEntity.name);
|
|
447
|
-
if (description)
|
|
448
|
-
builder.description(description);
|
|
449
815
|
// Z1: `first time` prose → RoomTrait.initialDescription (first look
|
|
450
816
|
// shows it, later looks show the standard description — stdlib's
|
|
451
817
|
// looking-data reads the field; no stdlib change).
|
|
452
818
|
const initialDescription = irEntity.initialDescriptionKey
|
|
453
819
|
? this.phraseText(irEntity.initialDescriptionKey)
|
|
454
820
|
: undefined;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
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 }));
|
|
462
827
|
break;
|
|
463
828
|
}
|
|
464
829
|
case 'container': {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
entity
|
|
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());
|
|
475
840
|
this.applyContainerConfig(entity, irEntity.kinds[0]);
|
|
476
841
|
break;
|
|
477
842
|
}
|
|
478
843
|
case 'person': {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
}));
|
|
485
860
|
break;
|
|
486
861
|
}
|
|
487
862
|
case 'supporter': {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
builder.description(description);
|
|
491
|
-
if (irEntity.aka.length)
|
|
492
|
-
builder.aliases(...irEntity.aka);
|
|
493
|
-
entity = builder.build();
|
|
863
|
+
entity = world.createEntity(irEntity.name, 'object');
|
|
864
|
+
entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
|
|
494
865
|
entity.add(new world_model_1.SupporterTrait({ capacity: supporterCapacity(irEntity.kinds[0]) }));
|
|
495
866
|
break;
|
|
496
867
|
}
|
|
497
|
-
case '
|
|
498
|
-
|
|
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
|
+
}
|
|
499
902
|
case null: {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
builder.description(description);
|
|
503
|
-
if (irEntity.aka.length)
|
|
504
|
-
builder.aliases(...irEntity.aka);
|
|
505
|
-
entity = builder.build();
|
|
903
|
+
entity = world.createEntity(irEntity.name, 'object');
|
|
904
|
+
entity.add(new world_model_1.IdentityTrait({ name: irEntity.name, description, aliases }));
|
|
506
905
|
break;
|
|
507
906
|
}
|
|
508
907
|
default:
|
|
509
|
-
throw new
|
|
908
|
+
throw new errors_js_1.LoadError(`\`${irEntity.name}\`: unknown kind noun \`${kind}\`.`, irEntity.span);
|
|
510
909
|
}
|
|
511
910
|
this.applyTraitAdjectives(entity, irEntity, kind);
|
|
911
|
+
this.applyStartsStates(entity, irEntity);
|
|
512
912
|
this.worldIds.set(irEntity.id, entity.id);
|
|
513
913
|
this.irIds.set(entity.id, irEntity.id);
|
|
514
914
|
return entity;
|
|
515
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
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Resolve trait-config entity references (`with tool X`, `with key X`)
|
|
942
|
+
* to world entity ids (ADR-230 D3c / Phase 9a) — runs once, after every
|
|
943
|
+
* entity is built.
|
|
944
|
+
*/
|
|
945
|
+
resolvePendingEntityRefs() {
|
|
946
|
+
for (const pending of this.pendingEntityRefs) {
|
|
947
|
+
const worldId = this.worldIds.get(pending.irRefId);
|
|
948
|
+
if (!worldId) {
|
|
949
|
+
throw new errors_js_1.LoadError(`\`${pending.ownerName}\`: a trait-config entity reference was never built.`, pending.span);
|
|
950
|
+
}
|
|
951
|
+
pending.apply(worldId);
|
|
952
|
+
}
|
|
953
|
+
this.pendingEntityRefs.length = 0;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* ADR-230 D3c load-time check (PIN 3, dual-surface re-pin, 2026-07-17):
|
|
957
|
+
* every cuttable entity must register exactly ONE cut implementation —
|
|
958
|
+
* an `on cutting it` clause (entity- or trait-level, loads as an
|
|
959
|
+
* ADR-228 interceptor) or an ADR-090 capability behavior for
|
|
960
|
+
* `if.action.cutting` (TS/hatch surface). Zero implementations would
|
|
961
|
+
* silently no-op at runtime; two would double-fire (ADR-228 D6 spirit).
|
|
962
|
+
* Chord surfaces are counted from the IR (precise per entity); the
|
|
963
|
+
* capability surface from the live world.
|
|
964
|
+
*/
|
|
965
|
+
checkCuttableImplementations(world) {
|
|
966
|
+
const toolGatedGerunds = [
|
|
967
|
+
{ adjective: 'cuttable', gerund: 'cutting', actionId: 'if.action.cutting' },
|
|
968
|
+
{ adjective: 'diggable', gerund: 'digging', actionId: 'if.action.digging' }, // ADR-230 Phase 6
|
|
969
|
+
];
|
|
970
|
+
for (const { adjective, gerund, actionId } of toolGatedGerunds) {
|
|
971
|
+
for (const irEntity of this.ir.entities) {
|
|
972
|
+
if (!irEntity.traits.some((t) => t.name === adjective))
|
|
973
|
+
continue;
|
|
974
|
+
const worldId = this.worldIds.get(irEntity.id);
|
|
975
|
+
const entity = worldId ? world.getEntity(worldId) : undefined;
|
|
976
|
+
if (!entity)
|
|
977
|
+
continue;
|
|
978
|
+
let surfaces = 0;
|
|
979
|
+
// Entity-level `on <gerund> it` clause.
|
|
980
|
+
if (irEntity.onClauses.some((c) => c.clauseKind === 'on' && c.action === gerund && c.binding !== 'every-turn')) {
|
|
981
|
+
surfaces++;
|
|
982
|
+
}
|
|
983
|
+
// Composed `define trait` with an `on <gerund> it` clause.
|
|
984
|
+
for (const comp of irEntity.traits) {
|
|
985
|
+
const def = this.ir.traits.find((t) => t.name === comp.name);
|
|
986
|
+
if (def?.onClauses.some((c) => c.clauseKind === 'on' && c.action === gerund && c.binding !== 'every-turn')) {
|
|
987
|
+
surfaces++;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// ADR-090 capability behavior (TS/hatch surface).
|
|
991
|
+
const capabilityTrait = (0, world_model_1.findTraitWithCapability)(entity, actionId);
|
|
992
|
+
if (capabilityTrait && world.getBehaviorForCapability(capabilityTrait, actionId)) {
|
|
993
|
+
surfaces++;
|
|
994
|
+
}
|
|
995
|
+
if (surfaces === 0) {
|
|
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);
|
|
997
|
+
}
|
|
998
|
+
if (surfaces > 1) {
|
|
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);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
516
1004
|
applyTraitAdjectives(entity, irEntity, kind) {
|
|
517
1005
|
for (const trait of irEntity.traits) {
|
|
518
1006
|
if (trait.condition !== null) {
|
|
@@ -525,12 +1013,18 @@ class ChordStory {
|
|
|
525
1013
|
continue;
|
|
526
1014
|
const def = this.ir.traits.find((t) => t.name === trait.name);
|
|
527
1015
|
if (def && def.onClauses.length > 0 && def.onClauses.every((c) => c.binding === 'every-turn')) {
|
|
528
|
-
entity.add(new ChordDataTrait(
|
|
1016
|
+
entity.add(new ChordDataTrait(state_keys_js_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
|
|
529
1017
|
continue;
|
|
530
1018
|
}
|
|
531
|
-
throw new
|
|
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);
|
|
532
1020
|
}
|
|
533
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;
|
|
534
1028
|
case 'scenery':
|
|
535
1029
|
if (!entity.has(world_model_1.TraitType.SCENERY))
|
|
536
1030
|
entity.add(new world_model_1.SceneryTrait());
|
|
@@ -541,23 +1035,149 @@ class ChordStory {
|
|
|
541
1035
|
case 'readable':
|
|
542
1036
|
entity.add(new world_model_1.ReadableTrait({ text: configValue(trait, 'text') ?? '' }));
|
|
543
1037
|
break;
|
|
544
|
-
case 'openable':
|
|
545
|
-
|
|
546
|
-
|
|
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);
|
|
547
1054
|
break;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1055
|
+
}
|
|
1056
|
+
case 'lockable': {
|
|
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.
|
|
1061
|
+
if (entity.has(world_model_1.TraitType.LOCKABLE))
|
|
1062
|
+
break;
|
|
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);
|
|
1070
|
+
if (keyName !== undefined) {
|
|
1071
|
+
this.pendingEntityRefs.push(this.entityRefFor(keyName, 'key', irEntity, trait.span, (worldId) => {
|
|
1072
|
+
lockable.keyId = worldId;
|
|
1073
|
+
}));
|
|
1074
|
+
}
|
|
1075
|
+
entity.add(lockable);
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
case 'cuttable':
|
|
1079
|
+
case 'diggable': {
|
|
1080
|
+
// ADR-230 D3c / Phase 6. Tool names resolve name → IR entity here
|
|
1081
|
+
// and IR → world id after every entity is built (forward refs are
|
|
1082
|
+
// legal) — do NOT copy the lockable raw-string config bug.
|
|
1083
|
+
const traitType = trait.name === 'cuttable' ? world_model_1.TraitType.CUTTABLE : world_model_1.TraitType.DIGGABLE;
|
|
1084
|
+
if (entity.has(traitType))
|
|
1085
|
+
break;
|
|
1086
|
+
const toolGated = trait.name === 'cuttable' ? new world_model_1.CuttableTrait() : new world_model_1.DiggableTrait();
|
|
1087
|
+
const toolName = entityConfigValue(trait);
|
|
1088
|
+
if (toolName !== undefined) {
|
|
1089
|
+
this.pendingEntityRefs.push(this.entityRefFor(toolName, 'tool', irEntity, trait.span, (worldId) => {
|
|
1090
|
+
toolGated.toolId = worldId;
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
entity.add(toolGated);
|
|
551
1094
|
break;
|
|
1095
|
+
}
|
|
552
1096
|
case 'switchable':
|
|
553
1097
|
entity.add(new world_model_1.SwitchableTrait());
|
|
554
1098
|
break;
|
|
555
1099
|
case 'edible':
|
|
556
|
-
|
|
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
|
+
}));
|
|
1148
|
+
break;
|
|
1149
|
+
}
|
|
1150
|
+
case 'enterable': // ADR-218 §1a (ratchet F1) — default config, preposition `in`
|
|
1151
|
+
if (!entity.has(world_model_1.TraitType.ENTERABLE))
|
|
1152
|
+
entity.add(new world_model_1.EnterableTrait());
|
|
1153
|
+
break;
|
|
1154
|
+
case 'climbable': // ADR-218 §1a (ratchet F2) — default config
|
|
1155
|
+
if (!entity.has(world_model_1.TraitType.CLIMBABLE))
|
|
1156
|
+
entity.add(new world_model_1.ClimbableTrait());
|
|
557
1157
|
break;
|
|
558
1158
|
case 'light-source':
|
|
559
1159
|
entity.add(new world_model_1.LightSourceTrait());
|
|
560
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
|
+
}
|
|
561
1181
|
case 'plural': {
|
|
562
1182
|
const identity = entity.get(world_model_1.TraitType.IDENTITY);
|
|
563
1183
|
if (identity)
|
|
@@ -566,7 +1186,7 @@ class ChordStory {
|
|
|
566
1186
|
}
|
|
567
1187
|
case 'dark': {
|
|
568
1188
|
if (kind !== 'room') {
|
|
569
|
-
throw new
|
|
1189
|
+
throw new errors_js_1.LoadError(`\`dark\` applies to rooms only.`, trait.span);
|
|
570
1190
|
}
|
|
571
1191
|
break; // unconditional dark handled by the room builder
|
|
572
1192
|
}
|
|
@@ -576,14 +1196,248 @@ class ChordStory {
|
|
|
576
1196
|
// via `set`, serialized with the world — AC-6-safe).
|
|
577
1197
|
const def = this.ir.traits.find((t) => t.name === trait.name);
|
|
578
1198
|
if (def) {
|
|
579
|
-
entity.add(new ChordDataTrait(
|
|
1199
|
+
entity.add(new ChordDataTrait(state_keys_js_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
|
|
580
1200
|
break;
|
|
581
1201
|
}
|
|
582
|
-
throw new
|
|
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);
|
|
583
1203
|
}
|
|
584
1204
|
}
|
|
585
1205
|
}
|
|
586
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);
|
|
1236
|
+
}
|
|
1237
|
+
data[route.field] = setting.value === 'true';
|
|
1238
|
+
}
|
|
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));
|
|
1440
|
+
}
|
|
587
1441
|
/**
|
|
588
1442
|
* Initial values for a `define trait` instance: declared `starts`
|
|
589
1443
|
* defaults overlaid by the composition's `with` config. Entity-name
|
|
@@ -600,7 +1454,7 @@ class ChordStory {
|
|
|
600
1454
|
const lower = setting.value.toLowerCase();
|
|
601
1455
|
const target = this.ir.entities.find((e) => e.name.toLowerCase() === lower || e.aka.includes(lower));
|
|
602
1456
|
if (!target) {
|
|
603
|
-
throw new
|
|
1457
|
+
throw new errors_js_1.LoadError(`\`${setting.value}\` (config \`${setting.key}\`) names no entity.`, comp.span);
|
|
604
1458
|
}
|
|
605
1459
|
values[setting.key] = target.id;
|
|
606
1460
|
}
|
|
@@ -627,7 +1481,7 @@ class ChordStory {
|
|
|
627
1481
|
requireWorldId(irId, at) {
|
|
628
1482
|
const id = this.worldIds.get(irId);
|
|
629
1483
|
if (!id) {
|
|
630
|
-
throw new
|
|
1484
|
+
throw new errors_js_1.LoadError(`Entity \`${irId}\` is referenced before it exists in the world.`, at?.span);
|
|
631
1485
|
}
|
|
632
1486
|
return id;
|
|
633
1487
|
}
|
|
@@ -679,9 +1533,9 @@ class ChordStory {
|
|
|
679
1533
|
if (phrase.verbatim) {
|
|
680
1534
|
// The analyzer already errors here (analysis.verbatim-marker);
|
|
681
1535
|
// this is the loader's defensive half of the same contract.
|
|
682
|
-
throw new
|
|
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);
|
|
683
1537
|
}
|
|
684
|
-
const variantTexts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0,
|
|
1538
|
+
const variantTexts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_js_1.withLineBreaks)(v.text)));
|
|
685
1539
|
let mentions;
|
|
686
1540
|
if (phrase.condition) {
|
|
687
1541
|
const subject = presenceSubject(phrase.condition, irEntity.id);
|
|
@@ -693,7 +1547,7 @@ class ChordStory {
|
|
|
693
1547
|
gates.push(() => (0, stdlib_1.registerSnippetGate)(entity.id, marker, () => this.evaluator.evalCondition(condition, { world })));
|
|
694
1548
|
}
|
|
695
1549
|
}
|
|
696
|
-
const selector = phrase.strategy ?
|
|
1550
|
+
const selector = phrase.strategy ? runtime_js_1.STRATEGY_SELECTOR[phrase.strategy] : undefined;
|
|
697
1551
|
let entry;
|
|
698
1552
|
if (!selector && variantTexts.length === 1) {
|
|
699
1553
|
// Single-variant plain phrase → plain string entry, never a Choice.
|
|
@@ -788,7 +1642,7 @@ class ChordStory {
|
|
|
788
1642
|
break;
|
|
789
1643
|
if (!phrase.condition)
|
|
790
1644
|
continue; // analyzer already errored (detail-unconditional)
|
|
791
|
-
const text = (0,
|
|
1645
|
+
const text = (0, text_js_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
|
|
792
1646
|
const shape = detailTraitShape(phrase.condition, irEntity.id);
|
|
793
1647
|
if (shape === 'on' && entity.has(world_model_1.TraitType.SWITCHABLE)) {
|
|
794
1648
|
entity.get(world_model_1.TraitType.SWITCHABLE).detailWhenOn = text;
|
|
@@ -822,9 +1676,31 @@ class ChordStory {
|
|
|
822
1676
|
phraseText(key) {
|
|
823
1677
|
const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
|
|
824
1678
|
if (!phrase) {
|
|
825
|
-
throw new
|
|
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));
|
|
826
1703
|
}
|
|
827
|
-
return (0, text_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
|
|
828
1704
|
}
|
|
829
1705
|
}
|
|
830
1706
|
exports.ChordStory = ChordStory;
|
|
@@ -869,11 +1745,26 @@ function presenceSubject(cond, roomIrId) {
|
|
|
869
1745
|
}
|
|
870
1746
|
return null;
|
|
871
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
|
+
]);
|
|
872
1763
|
/** Chord direction word → world-model DirectionType. */
|
|
873
1764
|
function toDirection(word, at) {
|
|
874
1765
|
const dir = world_model_1.Direction[word.toUpperCase()];
|
|
875
1766
|
if (!dir)
|
|
876
|
-
throw new
|
|
1767
|
+
throw new errors_js_1.LoadError(`Unknown direction \`${word}\`.`, at?.span);
|
|
877
1768
|
return dir;
|
|
878
1769
|
}
|
|
879
1770
|
/** `with capacity N` on a supporter kind. */
|
|
@@ -884,6 +1775,14 @@ function supporterCapacity(kind) {
|
|
|
884
1775
|
function configValue(comp, key) {
|
|
885
1776
|
return comp.config.find((c) => c.key === key)?.value;
|
|
886
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
|
+
}
|
|
887
1786
|
/**
|
|
888
1787
|
* The Language Provider template for an IR phrase. Single-variant phrases
|
|
889
1788
|
* register their text (with `{br}` mapped to hard line breaks); verbatim
|
|
@@ -894,7 +1793,7 @@ function templateFor(phrase) {
|
|
|
894
1793
|
if (phrase.verbatim)
|
|
895
1794
|
return '{verbatim:text}';
|
|
896
1795
|
if (phrase.strategy === null && phrase.variants.length === 1)
|
|
897
|
-
return (0,
|
|
1796
|
+
return (0, text_js_1.withLineBreaks)(phrase.variants[0].text);
|
|
898
1797
|
return '{variants}';
|
|
899
1798
|
}
|
|
900
1799
|
/**
|
|
@@ -911,7 +1810,7 @@ function toVocabularyVerb(verb) {
|
|
|
911
1810
|
const KNOWN = { 'put on': 'PUT_ON' };
|
|
912
1811
|
const actionId = KNOWN[`${base} ${preposition ?? ''}`.trim()];
|
|
913
1812
|
if (!actionId || slots !== 2) {
|
|
914
|
-
throw new
|
|
1813
|
+
throw new errors_js_1.LoadError(`\`define verb ${verb.verbs.join(' or ')}\` maps to \`${words.join(' ')}\`, which the Phase A loader cannot register.`);
|
|
915
1814
|
}
|
|
916
1815
|
return {
|
|
917
1816
|
actionId,
|