@sharpee/story-loader 3.0.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/errors.d.ts +20 -0
- package/errors.d.ts.map +1 -0
- package/errors.js +16 -0
- package/errors.js.map +1 -0
- package/evaluator.d.ts +102 -0
- package/evaluator.d.ts.map +1 -0
- package/evaluator.js +384 -0
- package/evaluator.js.map +1 -0
- package/event-contract.d.ts +39 -0
- package/event-contract.d.ts.map +1 -0
- package/event-contract.js +35 -0
- package/event-contract.js.map +1 -0
- package/hatch-context.d.ts +57 -0
- package/hatch-context.d.ts.map +1 -0
- package/hatch-context.js +92 -0
- package/hatch-context.js.map +1 -0
- package/index.d.ts +24 -0
- package/index.d.ts.map +1 -0
- package/index.js +44 -0
- package/index.js.map +1 -0
- package/loader.d.ts +241 -0
- package/loader.d.ts.map +1 -0
- package/loader.js +923 -0
- package/loader.js.map +1 -0
- package/package.json +50 -0
- package/runtime.d.ts +250 -0
- package/runtime.d.ts.map +1 -0
- package/runtime.js +1249 -0
- package/runtime.js.map +1 -0
- package/state-keys.d.ts +23 -0
- package/state-keys.d.ts.map +1 -0
- package/state-keys.js +26 -0
- package/state-keys.js.map +1 -0
- package/text.d.ts +19 -0
- package/text.d.ts.map +1 -0
- package/text.js +24 -0
- package/text.js.map +1 -0
package/loader.js
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChordStory = exports.ChordDataTrait = exports.ChordDetailTrait = void 0;
|
|
4
|
+
exports.createStory = createStory;
|
|
5
|
+
/**
|
|
6
|
+
* loader.ts — the generic `Story` constructed from Chord Story IR (ADR-210).
|
|
7
|
+
*
|
|
8
|
+
* Purpose: interpret a compiled IR into the platform's standard story
|
|
9
|
+
* lifecycle: world building (`initializeWorld`), player creation
|
|
10
|
+
* (`createPlayer`), phrase registration (`extendLanguage`), custom verbs
|
|
11
|
+
* (`getCustomVocabulary`), and completion (`isComplete` via the if-domain
|
|
12
|
+
* ending flag). Phase A slice: static world only — when-rules, on-clause
|
|
13
|
+
* interceptors, derived properties, and the evaluator bind in Phase 5.
|
|
14
|
+
*
|
|
15
|
+
* Public interface: createStory(), ChordStory, StoryLoaderOptions.
|
|
16
|
+
* Owner context: @sharpee/story-loader (language-neutral IR consumer; the
|
|
17
|
+
* runtime platform never depends on this package — ADR-210 Direction rule).
|
|
18
|
+
*
|
|
19
|
+
* Invariants:
|
|
20
|
+
* - Atomic load: any defect throws LoadError; no partial registration.
|
|
21
|
+
* - No filesystem access: hatch modules arrive pre-loaded via options
|
|
22
|
+
* (the CLI/devkit owns module resolution and compilation).
|
|
23
|
+
* - Every phrase key in the IR is registered with the Language Provider
|
|
24
|
+
* (given 3); blocked-exit/description text is ALSO written where the
|
|
25
|
+
* platform reads it today (dual-mode, ADR-107).
|
|
26
|
+
*/
|
|
27
|
+
const chord_1 = require("@sharpee/chord");
|
|
28
|
+
const stdlib_1 = require("@sharpee/stdlib");
|
|
29
|
+
const if_domain_1 = require("@sharpee/if-domain");
|
|
30
|
+
const helpers_1 = require("@sharpee/helpers");
|
|
31
|
+
const plugin_scheduler_1 = require("@sharpee/plugin-scheduler");
|
|
32
|
+
const world_model_1 = require("@sharpee/world-model");
|
|
33
|
+
const errors_1 = require("./errors");
|
|
34
|
+
const evaluator_1 = require("./evaluator");
|
|
35
|
+
const hatch_context_1 = require("./hatch-context");
|
|
36
|
+
const runtime_1 = require("./runtime");
|
|
37
|
+
const state_keys_1 = require("./state-keys");
|
|
38
|
+
const text_1 = require("./text");
|
|
39
|
+
/**
|
|
40
|
+
* Marker trait for entities carrying loader-compiled `detail` providers
|
|
41
|
+
* (Z3b): the one state-clause contributor registered per load looks up the
|
|
42
|
+
* owner's gated detail specs through it. Data-free — the specs live on the
|
|
43
|
+
* loader (nothing serialized; re-registered every load).
|
|
44
|
+
*/
|
|
45
|
+
class ChordDetailTrait {
|
|
46
|
+
static type = 'chord.detail';
|
|
47
|
+
type = ChordDetailTrait.type;
|
|
48
|
+
}
|
|
49
|
+
exports.ChordDetailTrait = ChordDetailTrait;
|
|
50
|
+
/**
|
|
51
|
+
* A `define trait` runtime instance: type `chord.trait.<name>`, data fields
|
|
52
|
+
* as own enumerable properties (world serialization covers them — AC-6).
|
|
53
|
+
*/
|
|
54
|
+
class ChordDataTrait {
|
|
55
|
+
type;
|
|
56
|
+
constructor(type, values) {
|
|
57
|
+
this.type = type;
|
|
58
|
+
Object.assign(this, values);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
exports.ChordDataTrait = ChordDataTrait;
|
|
62
|
+
/**
|
|
63
|
+
* Build a `Story` from compiled IR.
|
|
64
|
+
* @param ir a gate-clean Story IR (`compile().ok` was true)
|
|
65
|
+
* @param options hatch modules and host wiring
|
|
66
|
+
* @throws LoadError on format mismatch or unbindable hatch (atomic load)
|
|
67
|
+
*/
|
|
68
|
+
function createStory(ir, options = {}) {
|
|
69
|
+
return new ChordStory(ir, options);
|
|
70
|
+
}
|
|
71
|
+
/** The generic Story implementation interpreted from IR. */
|
|
72
|
+
class ChordStory {
|
|
73
|
+
ir;
|
|
74
|
+
config;
|
|
75
|
+
/** Bound `define text` producers by hatch name. */
|
|
76
|
+
producers = new Map();
|
|
77
|
+
/** Bound `define action X from` hatches: four-phase Action objects by name. */
|
|
78
|
+
boundActions = new Map();
|
|
79
|
+
/** Bound `define behavior X from` hatches: CapabilityBehaviors by name. */
|
|
80
|
+
boundBehaviors = new Map();
|
|
81
|
+
/** The turn-by-turn runtime (rules, on-clauses, derived properties). */
|
|
82
|
+
runtime;
|
|
83
|
+
/** The condition evaluator — shared with the runtime; Z2 gate thunks close over it. */
|
|
84
|
+
evaluator;
|
|
85
|
+
/** IR entity ID → world entity ID (populated by initializeWorld/createPlayer). */
|
|
86
|
+
worldIds = new Map();
|
|
87
|
+
/** World entity ID → IR entity ID (state lookups in the evaluator). */
|
|
88
|
+
irIds = new Map();
|
|
89
|
+
world = null;
|
|
90
|
+
/** True once initializeWorld has built the world content. */
|
|
91
|
+
worldBuilt = false;
|
|
92
|
+
/** True once the player has been placed/equipped (exactly-once guard). */
|
|
93
|
+
playerFinalized = false;
|
|
94
|
+
constructor(ir, options) {
|
|
95
|
+
this.ir = ir;
|
|
96
|
+
if (ir.format !== chord_1.IR_FORMAT) {
|
|
97
|
+
throw new errors_1.LoadError(`Unsupported IR format \`${String(ir.format)}\` — this loader reads \`${chord_1.IR_FORMAT}\`.`);
|
|
98
|
+
}
|
|
99
|
+
this.config = {
|
|
100
|
+
id: ir.meta.fields.id ?? ir.meta.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
101
|
+
title: ir.meta.title,
|
|
102
|
+
author: ir.meta.author,
|
|
103
|
+
version: ir.meta.fields.version ?? '0.0.0',
|
|
104
|
+
description: ir.meta.fields.blurb,
|
|
105
|
+
};
|
|
106
|
+
this.bindHatches(options);
|
|
107
|
+
this.evaluator = new evaluator_1.Evaluator(ir, this, options.seed);
|
|
108
|
+
this.runtime = new runtime_1.ChordRuntime(ir, this, this.evaluator);
|
|
109
|
+
}
|
|
110
|
+
/** The world entity ID for an IR entity ID (after initializeWorld). */
|
|
111
|
+
entityId(irId) {
|
|
112
|
+
return this.worldIds.get(irId);
|
|
113
|
+
}
|
|
114
|
+
/** The IR entity ID for a world entity ID. */
|
|
115
|
+
irIdOf(worldId) {
|
|
116
|
+
return this.irIds.get(worldId);
|
|
117
|
+
}
|
|
118
|
+
/** The player's world id, once createPlayer has run. */
|
|
119
|
+
playerWorldId() {
|
|
120
|
+
return this.playerId;
|
|
121
|
+
}
|
|
122
|
+
playerId;
|
|
123
|
+
bindHatches(options) {
|
|
124
|
+
// AC-4, pure-IR profile: refuse hatch-bearing stories BEFORE touching
|
|
125
|
+
// any module — no author-supplied code is read, called, or bound.
|
|
126
|
+
if ((options.profile ?? 'devkit') === 'pure-ir' && this.ir.hatches.length > 0) {
|
|
127
|
+
const names = this.ir.hatches.map((h) => `\`${h.name}\` (${h.modulePath})`).join(', ');
|
|
128
|
+
throw new errors_1.LoadError(`This profile runs pure-IR stories only — the story declares ${this.ir.hatches.length} TS hatch(es): ${names}. Load it with the devkit profile, or remove the hatches.`);
|
|
129
|
+
}
|
|
130
|
+
for (const hatch of this.ir.hatches) {
|
|
131
|
+
const module = options.hatchModules?.[hatch.modulePath];
|
|
132
|
+
if (!module) {
|
|
133
|
+
throw new errors_1.LoadError(`Hatch module \`${hatch.modulePath}\` was not provided to the loader.`, hatch.span);
|
|
134
|
+
}
|
|
135
|
+
const bound = module[hatch.name];
|
|
136
|
+
// Bind-time `'chord.'` lint (design.md §5.6, best-effort backstop —
|
|
137
|
+
// the staging facade is the wall): the loader-private state namespace
|
|
138
|
+
// is off-limits to hatches; a quoted literal fails the bind atomically,
|
|
139
|
+
// like a missing export. The devkit source lint is the authoritative
|
|
140
|
+
// layer (this one can miss minified code and can trip on a quoted
|
|
141
|
+
// literal inside a compiled-in comment — reword the comment).
|
|
142
|
+
const chordLiteral = (0, hatch_context_1.findChordLiteral)(bound);
|
|
143
|
+
if (chordLiteral !== null) {
|
|
144
|
+
throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` references the loader-private \`chord.*\` state namespace (\`${chordLiteral}\`) — hatches read the world through their context only (design.md §5.6). If the match is inside a comment, reword it.`, hatch.span);
|
|
145
|
+
}
|
|
146
|
+
const kind = hatch.hatchKind ?? 'text';
|
|
147
|
+
switch (kind) {
|
|
148
|
+
case 'text': {
|
|
149
|
+
if (typeof bound !== 'function') {
|
|
150
|
+
throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not a function'} — expected a dynamic-text producer export.`, hatch.span);
|
|
151
|
+
}
|
|
152
|
+
this.producers.set(hatch.name, bound);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'action': {
|
|
156
|
+
// Interface Contract 3: the export IS a four-phase Action.
|
|
157
|
+
const action = bound;
|
|
158
|
+
if (!action || typeof action !== 'object' || typeof action.validate !== 'function' || typeof action.execute !== 'function') {
|
|
159
|
+
throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not an Action'} — expected a four-phase Action export (validate/execute/report/blocked).`, hatch.span);
|
|
160
|
+
}
|
|
161
|
+
this.boundActions.set(hatch.name, bound);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case 'behavior': {
|
|
165
|
+
const behavior = bound;
|
|
166
|
+
if (!behavior || typeof behavior !== 'object' || typeof behavior.validate !== 'function' || typeof behavior.execute !== 'function' || typeof behavior.report !== 'function') {
|
|
167
|
+
throw new errors_1.LoadError(`Hatch \`${hatch.name}\` in \`${hatch.modulePath}\` is ${bound === undefined ? 'missing' : 'not a CapabilityBehavior'} — expected a validate/execute/report export.`, hatch.span);
|
|
168
|
+
}
|
|
169
|
+
this.boundBehaviors.set(hatch.name, bound);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ------------------------------------------------------------ lifecycle
|
|
176
|
+
initializeWorld(world) {
|
|
177
|
+
this.world = world;
|
|
178
|
+
const built = [];
|
|
179
|
+
// Pass 1 — create every non-player entity.
|
|
180
|
+
for (const irEntity of this.ir.entities) {
|
|
181
|
+
if (irEntity.isPlayer)
|
|
182
|
+
continue;
|
|
183
|
+
built.push({ ir: irEntity, entity: this.buildEntity(world, irEntity) });
|
|
184
|
+
}
|
|
185
|
+
// Pass 2 — placement, exits, blocked exits, initial states.
|
|
186
|
+
for (const { ir: irEntity, entity } of built) {
|
|
187
|
+
if (irEntity.placement && irEntity.placement.relation !== 'starts-in') {
|
|
188
|
+
world.moveEntity(entity.id, this.requireWorldId(irEntity.placement.place, irEntity));
|
|
189
|
+
}
|
|
190
|
+
for (const exit of irEntity.exits) {
|
|
191
|
+
world.connectRooms(entity.id, this.requireWorldId(exit.to, irEntity), toDirection(exit.direction, irEntity));
|
|
192
|
+
}
|
|
193
|
+
for (const blocked of irEntity.blockedExits) {
|
|
194
|
+
// Unconditional blocks are static; `is blocked while <cond>` blocks
|
|
195
|
+
// are derived — the runtime recomputes them with dark-while (grammar
|
|
196
|
+
// log 2026-07-10; initial evaluation at player finalization).
|
|
197
|
+
if (blocked.condition === null) {
|
|
198
|
+
world_model_1.RoomBehavior.blockExit(entity, toDirection(blocked.direction, irEntity), this.phraseText(blocked.phraseKey));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (irEntity.states.length > 0) {
|
|
202
|
+
world.setStateValue(state_keys_1.CHORD_STATE_PREFIX + irEntity.id, irEntity.states[0]);
|
|
203
|
+
}
|
|
204
|
+
// Z2 (ADR-211): compile `{key}` description markers onto ADR-209
|
|
205
|
+
// snippet storage — atomically per room, before the engine's
|
|
206
|
+
// load-time `validateRoomSnippets` gate ever sees the texts.
|
|
207
|
+
if (entity.has(world_model_1.TraitType.ROOM)) {
|
|
208
|
+
this.compileRoomSnippets(world, irEntity, entity);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// The story object starts in its first declared phase (ratchet D2).
|
|
212
|
+
if (this.ir.story.states.length > 0) {
|
|
213
|
+
world.setStateValue(state_keys_1.CHORD_STORY_STATE_KEY, this.ir.story.states[0]);
|
|
214
|
+
}
|
|
215
|
+
// Declared scores set the ceiling (dedup-by-identity makes the sum
|
|
216
|
+
// exact — ADR-129).
|
|
217
|
+
if (this.ir.scores.length > 0) {
|
|
218
|
+
world.setMaxScore(this.ir.scores.reduce((sum, s) => sum + s.worth, 0));
|
|
219
|
+
}
|
|
220
|
+
// Z3 (ADR-213): one pre-removal observer serves every authored
|
|
221
|
+
// `disappeared` block — witnessed-only, enqueued for the report pass.
|
|
222
|
+
this.registerRemovalObserver(world);
|
|
223
|
+
// Z3b: gated `detail` blocks — shipped trait fields where the condition
|
|
224
|
+
// matches them, a loader-owned state-clause provider for everything else.
|
|
225
|
+
this.compileDetailChannels(world);
|
|
226
|
+
// Bind the turn-by-turn runtime: rules, on-clause interceptors,
|
|
227
|
+
// derived-property chains (all per-world, keyed — ADR-207/208).
|
|
228
|
+
this.runtime.bind(world);
|
|
229
|
+
this.worldBuilt = true;
|
|
230
|
+
// Engine order (GameEngine.setStory): createPlayer ran before world
|
|
231
|
+
// content existed — the player can be placed and equipped only now.
|
|
232
|
+
if (this.playerId)
|
|
233
|
+
this.finalizePlayer(world);
|
|
234
|
+
}
|
|
235
|
+
createPlayer(world) {
|
|
236
|
+
const irPlayer = this.ir.entities.find((e) => e.isPlayer) ?? null;
|
|
237
|
+
const description = irPlayer?.descriptionKey ? this.phraseText(irPlayer.descriptionKey) : 'An adventurer.';
|
|
238
|
+
const player = world.createEntity('yourself', 'actor');
|
|
239
|
+
player.add(new world_model_1.IdentityTrait({
|
|
240
|
+
name: 'yourself',
|
|
241
|
+
description,
|
|
242
|
+
aliases: ['self', 'me', 'myself', ...(irPlayer?.aka ?? [])],
|
|
243
|
+
properName: true,
|
|
244
|
+
article: '',
|
|
245
|
+
}));
|
|
246
|
+
player.add(new world_model_1.ActorTrait({ isPlayer: true }));
|
|
247
|
+
player.add(new world_model_1.ContainerTrait({ capacity: { maxItems: 10 } }));
|
|
248
|
+
this.playerId = player.id;
|
|
249
|
+
if (irPlayer) {
|
|
250
|
+
this.worldIds.set(irPlayer.id, player.id);
|
|
251
|
+
this.irIds.set(player.id, irPlayer.id);
|
|
252
|
+
}
|
|
253
|
+
// Direct/test order: the world is already built, finalize immediately.
|
|
254
|
+
// Engine order (setStory calls createPlayer FIRST, then initializeWorld
|
|
255
|
+
// — "player must exist first"): initializeWorld finalizes instead.
|
|
256
|
+
if (this.worldBuilt)
|
|
257
|
+
this.finalizePlayer(world);
|
|
258
|
+
return player;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Place and equip the player, then run the initial derived-property
|
|
262
|
+
* evaluation. Runs exactly once, from whichever lifecycle hook fires
|
|
263
|
+
* second — both the engine order (createPlayer → initializeWorld) and
|
|
264
|
+
* the direct order (initializeWorld → createPlayer) are supported.
|
|
265
|
+
*/
|
|
266
|
+
finalizePlayer(world) {
|
|
267
|
+
if (this.playerFinalized)
|
|
268
|
+
return;
|
|
269
|
+
this.playerFinalized = true;
|
|
270
|
+
const irPlayer = this.ir.entities.find((e) => e.isPlayer) ?? null;
|
|
271
|
+
const player = world.getEntity(this.playerId);
|
|
272
|
+
// Starting location: the player's `starts in`, else the first room.
|
|
273
|
+
const startIr = irPlayer?.placement?.relation === 'starts-in'
|
|
274
|
+
? irPlayer.placement.place
|
|
275
|
+
: this.ir.entities.find((e) => e.kinds.some((k) => k.name === 'room'))?.id;
|
|
276
|
+
if (startIr) {
|
|
277
|
+
world.moveEntity(player.id, this.requireWorldId(startIr, irPlayer ?? undefined));
|
|
278
|
+
}
|
|
279
|
+
// Worn items: into inventory, marked worn.
|
|
280
|
+
for (const wornIrId of irPlayer?.wears ?? []) {
|
|
281
|
+
const wornId = this.requireWorldId(wornIrId, irPlayer ?? undefined);
|
|
282
|
+
world.moveEntity(wornId, player.id);
|
|
283
|
+
const worn = world.getEntity(wornId);
|
|
284
|
+
const wearable = worn?.get(world_model_1.TraitType.WEARABLE);
|
|
285
|
+
if (!wearable) {
|
|
286
|
+
throw new errors_1.LoadError(`\`${wornIrId}\` is worn by the player but is not wearable.`, irPlayer?.span);
|
|
287
|
+
}
|
|
288
|
+
wearable.worn = true;
|
|
289
|
+
wearable.wornBy = player.id;
|
|
290
|
+
}
|
|
291
|
+
// Initial evaluation of derived properties (`dark while`) — the player
|
|
292
|
+
// and their possessions now exist, so conditions are decidable.
|
|
293
|
+
this.runtime.recomputeDerived(world);
|
|
294
|
+
}
|
|
295
|
+
extendLanguage(language) {
|
|
296
|
+
// `addMessage` is the concrete providers' registration surface
|
|
297
|
+
// (lang-en-us et al.), not part of if-domain's read interface — probe
|
|
298
|
+
// structurally so the loader stays locale-neutral.
|
|
299
|
+
const registry = language;
|
|
300
|
+
if (typeof registry.addMessage !== 'function') {
|
|
301
|
+
throw new errors_1.LoadError('The language provider does not support message registration (addMessage).');
|
|
302
|
+
}
|
|
303
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
304
|
+
for (const [key, phrase] of Object.entries(table)) {
|
|
305
|
+
registry.addMessage(key, templateFor(phrase));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
getCustomVocabulary() {
|
|
309
|
+
return { verbs: this.ir.verbs.map((v) => toVocabularyVerb(v)) };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Custom actions for engine registration: `define action` dispatch
|
|
313
|
+
* actions (Phase B, §5.4) plus `define action X from` hatch Actions
|
|
314
|
+
* (grammar for hatch actions is the module's own concern).
|
|
315
|
+
*/
|
|
316
|
+
getCustomActions() {
|
|
317
|
+
return [...this.runtime.buildDispatchActions(), ...this.boundActions.values()];
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Register scheduler constructs (`once`/`every`/`define sequence`/
|
|
321
|
+
* every-turn trait clauses) as plugin-scheduler daemons. All progression
|
|
322
|
+
* state is world state — no runner-state plumbing (design.md §6).
|
|
323
|
+
*/
|
|
324
|
+
onEngineReady(engine) {
|
|
325
|
+
const daemons = this.runtime.buildSchedulerDaemons();
|
|
326
|
+
if (daemons.length > 0) {
|
|
327
|
+
const plugin = new plugin_scheduler_1.SchedulerPlugin();
|
|
328
|
+
engine.getPluginRegistry().register(plugin);
|
|
329
|
+
const scheduler = plugin.getScheduler();
|
|
330
|
+
for (const daemon of daemons)
|
|
331
|
+
scheduler.registerDaemon(daemon);
|
|
332
|
+
}
|
|
333
|
+
// Z3 (ADR-212 §5): every `phrase present:` block compiles to ONE
|
|
334
|
+
// declarative slot entry — no synthesized closures; the platform's
|
|
335
|
+
// built-in contributor evaluates them.
|
|
336
|
+
this.registerPresentEntries(engine);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Z3 `present` channel → ADR-212 slot entries. One `registerSlotEntry`
|
|
340
|
+
* call per authoring entity: `slotKey: 'here'`, owner = the entity,
|
|
341
|
+
* content = variants as a `Choice` per the Z5 table (single plain variant
|
|
342
|
+
* → `Literal`), order = declaration order, `counterKey: 'present'` (the
|
|
343
|
+
* §4 owner + channel-key convention — the Choice's own keys match it).
|
|
344
|
+
* An ungated block relies on the platform's `owner-present` default; a
|
|
345
|
+
* `while`-gated block uses the predicate seam, ANDed with the same
|
|
346
|
+
* presence check so the gate narrows the channel rather than replacing
|
|
347
|
+
* its semantics.
|
|
348
|
+
*
|
|
349
|
+
* @param engine the engine surface (structural — absent method is a no-op)
|
|
350
|
+
*/
|
|
351
|
+
registerPresentEntries(engine) {
|
|
352
|
+
if (!engine.registerSlotEntry || !this.world)
|
|
353
|
+
return;
|
|
354
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
355
|
+
let order = 0;
|
|
356
|
+
for (const irEntity of this.ir.entities) {
|
|
357
|
+
const phrase = table[`${irEntity.id}.present`];
|
|
358
|
+
if (!phrase)
|
|
359
|
+
continue;
|
|
360
|
+
const ownerWorldId = this.worldIds.get(irEntity.id);
|
|
361
|
+
if (!ownerWorldId)
|
|
362
|
+
continue;
|
|
363
|
+
const texts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_1.withLineBreaks)(v.text)));
|
|
364
|
+
const content = texts.length === 1 && !phrase.strategy
|
|
365
|
+
? { kind: 'literal', text: texts[0] }
|
|
366
|
+
: {
|
|
367
|
+
kind: 'choice',
|
|
368
|
+
alternatives: texts.map((text) => ({ kind: 'literal', text })),
|
|
369
|
+
selector: runtime_1.STRATEGY_SELECTOR[phrase.strategy ?? 'cycling'],
|
|
370
|
+
// ADR-212 §4 caller contract: entityId = owner, messageKey =
|
|
371
|
+
// counterKey — the platform warns on a mismatch.
|
|
372
|
+
entityId: ownerWorldId,
|
|
373
|
+
messageKey: 'present',
|
|
374
|
+
};
|
|
375
|
+
const condition = phrase.condition;
|
|
376
|
+
engine.registerSlotEntry({
|
|
377
|
+
slotKey: 'here',
|
|
378
|
+
owner: ownerWorldId,
|
|
379
|
+
content,
|
|
380
|
+
order: order++,
|
|
381
|
+
counterKey: 'present',
|
|
382
|
+
...(condition
|
|
383
|
+
? {
|
|
384
|
+
gate: {
|
|
385
|
+
kind: 'predicate',
|
|
386
|
+
holds: (world) => {
|
|
387
|
+
const playerId = world.getPlayer()?.id;
|
|
388
|
+
const playerRoom = playerId ? world.getContainingRoom(playerId)?.id : undefined;
|
|
389
|
+
return (playerRoom !== undefined &&
|
|
390
|
+
world.getContainingRoom(ownerWorldId)?.id === playerRoom &&
|
|
391
|
+
this.evaluator.evalCondition(condition, { world }));
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
: {}),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Register `define action` grammar patterns as story grammar (ADR-087).
|
|
401
|
+
* The param is the Story contract's stdlib Parser; typed structurally at
|
|
402
|
+
* the use site to keep story-loader's dependency surface unchanged.
|
|
403
|
+
*/
|
|
404
|
+
extendParser(parser) {
|
|
405
|
+
const grammar = parser.getStoryGrammar();
|
|
406
|
+
for (const action of this.ir.actions) {
|
|
407
|
+
for (const pattern of action.patterns) {
|
|
408
|
+
if (pattern.cardinality)
|
|
409
|
+
continue; // `→ each …` expansion is engine-owned (Phase C)
|
|
410
|
+
const text = pattern.parts
|
|
411
|
+
.map((part) => (part.kind === 'slot' ? `:${part.word}` : part.word))
|
|
412
|
+
.join(' ');
|
|
413
|
+
grammar.define(text).mapsTo(`chord.action.${action.name}`).withPriority(150).build();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
isComplete() {
|
|
418
|
+
return this.world != null && this.world.getStateValue(if_domain_1.STORY_ENDING_FLAG) != null;
|
|
419
|
+
}
|
|
420
|
+
// ------------------------------------------------------------- endings
|
|
421
|
+
/**
|
|
422
|
+
* End the story: set the if-domain ending flag and build the blessed
|
|
423
|
+
* ending event (Prerequisite 3). The caller (rule evaluator) emits it.
|
|
424
|
+
*/
|
|
425
|
+
triggerEnding(world, ending, messageId) {
|
|
426
|
+
world.setStateValue(if_domain_1.STORY_ENDING_FLAG, ending);
|
|
427
|
+
return {
|
|
428
|
+
id: `${this.config.id}-${ending}-${world.getStateValue('chord.turn') ?? 0}`,
|
|
429
|
+
type: ending === 'victory' ? if_domain_1.StoryEndingEvents.VICTORY : if_domain_1.StoryEndingEvents.DEFEAT,
|
|
430
|
+
timestamp: Date.now(),
|
|
431
|
+
entities: {},
|
|
432
|
+
data: { ending, ...(messageId ? { messageId } : {}) },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// ------------------------------------------------------- entity build
|
|
436
|
+
buildEntity(world, irEntity) {
|
|
437
|
+
if (irEntity.kinds.length > 1) {
|
|
438
|
+
throw new errors_1.LoadError(`\`${irEntity.name}\` declares more than one kind noun.`, irEntity.span);
|
|
439
|
+
}
|
|
440
|
+
const kind = irEntity.kinds[0]?.name ?? null;
|
|
441
|
+
const description = irEntity.descriptionKey ? this.phraseText(irEntity.descriptionKey) : undefined;
|
|
442
|
+
const h = (0, helpers_1.createHelpers)(world);
|
|
443
|
+
let entity;
|
|
444
|
+
switch (kind) {
|
|
445
|
+
case 'room': {
|
|
446
|
+
const builder = h.room(irEntity.name);
|
|
447
|
+
if (description)
|
|
448
|
+
builder.description(description);
|
|
449
|
+
// Z1: `first time` prose → RoomTrait.initialDescription (first look
|
|
450
|
+
// shows it, later looks show the standard description — stdlib's
|
|
451
|
+
// looking-data reads the field; no stdlib change).
|
|
452
|
+
const initialDescription = irEntity.initialDescriptionKey
|
|
453
|
+
? this.phraseText(irEntity.initialDescriptionKey)
|
|
454
|
+
: undefined;
|
|
455
|
+
if (initialDescription)
|
|
456
|
+
builder.initialDescription(initialDescription);
|
|
457
|
+
if (irEntity.aka.length)
|
|
458
|
+
builder.aliases(...irEntity.aka);
|
|
459
|
+
if (irEntity.traits.some((t) => t.name === 'dark' && t.condition === null))
|
|
460
|
+
builder.dark();
|
|
461
|
+
entity = builder.build();
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case 'container': {
|
|
465
|
+
const builder = h.container(irEntity.name);
|
|
466
|
+
if (description)
|
|
467
|
+
builder.description(description);
|
|
468
|
+
if (irEntity.aka.length)
|
|
469
|
+
builder.aliases(...irEntity.aka);
|
|
470
|
+
if (irEntity.traits.some((t) => t.name === 'openable'))
|
|
471
|
+
builder.openable();
|
|
472
|
+
if (irEntity.traits.some((t) => t.name === 'lockable'))
|
|
473
|
+
builder.lockable();
|
|
474
|
+
entity = builder.build();
|
|
475
|
+
this.applyContainerConfig(entity, irEntity.kinds[0]);
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case 'person': {
|
|
479
|
+
const builder = h.actor(irEntity.name);
|
|
480
|
+
if (description)
|
|
481
|
+
builder.description(description);
|
|
482
|
+
if (irEntity.aka.length)
|
|
483
|
+
builder.aliases(...irEntity.aka);
|
|
484
|
+
entity = builder.build();
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
case 'supporter': {
|
|
488
|
+
const builder = h.object(irEntity.name);
|
|
489
|
+
if (description)
|
|
490
|
+
builder.description(description);
|
|
491
|
+
if (irEntity.aka.length)
|
|
492
|
+
builder.aliases(...irEntity.aka);
|
|
493
|
+
entity = builder.build();
|
|
494
|
+
entity.add(new world_model_1.SupporterTrait({ capacity: supporterCapacity(irEntity.kinds[0]) }));
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case 'door':
|
|
498
|
+
throw new errors_1.LoadError(`\`${irEntity.name}\`: doors need \`between\` placement, which the Phase A loader does not support yet.`, irEntity.span);
|
|
499
|
+
case null: {
|
|
500
|
+
const builder = h.object(irEntity.name);
|
|
501
|
+
if (description)
|
|
502
|
+
builder.description(description);
|
|
503
|
+
if (irEntity.aka.length)
|
|
504
|
+
builder.aliases(...irEntity.aka);
|
|
505
|
+
entity = builder.build();
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
default:
|
|
509
|
+
throw new errors_1.LoadError(`\`${irEntity.name}\`: unknown kind noun \`${kind}\`.`, irEntity.span);
|
|
510
|
+
}
|
|
511
|
+
this.applyTraitAdjectives(entity, irEntity, kind);
|
|
512
|
+
this.worldIds.set(irEntity.id, entity.id);
|
|
513
|
+
this.irIds.set(entity.id, irEntity.id);
|
|
514
|
+
return entity;
|
|
515
|
+
}
|
|
516
|
+
applyTraitAdjectives(entity, irEntity, kind) {
|
|
517
|
+
for (const trait of irEntity.traits) {
|
|
518
|
+
if (trait.condition !== null) {
|
|
519
|
+
// Conditional composition legality (Prerequisite 2): room-`dark`
|
|
520
|
+
// (the Phase A derived property), or a declared trait whose clauses
|
|
521
|
+
// are ALL NPC-behavior-shaped (`on every turn …`) — the scheduler
|
|
522
|
+
// daemon evaluates the composition condition per turn (`chatty
|
|
523
|
+
// while not after-hours`). Anything else is the load error.
|
|
524
|
+
if (trait.name === 'dark' && kind === 'room')
|
|
525
|
+
continue;
|
|
526
|
+
const def = this.ir.traits.find((t) => t.name === trait.name);
|
|
527
|
+
if (def && def.onClauses.length > 0 && def.onClauses.every((c) => c.binding === 'every-turn')) {
|
|
528
|
+
entity.add(new ChordDataTrait(state_keys_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
throw new errors_1.LoadError(`Conditional composition isn't supported for \`${trait.name}\` — move the condition inside the trait (\`on <action> it\` clauses can test it) or split the behavior.`, trait.span);
|
|
532
|
+
}
|
|
533
|
+
switch (trait.name) {
|
|
534
|
+
case 'scenery':
|
|
535
|
+
if (!entity.has(world_model_1.TraitType.SCENERY))
|
|
536
|
+
entity.add(new world_model_1.SceneryTrait());
|
|
537
|
+
break;
|
|
538
|
+
case 'wearable':
|
|
539
|
+
entity.add(new world_model_1.WearableTrait({}));
|
|
540
|
+
break;
|
|
541
|
+
case 'readable':
|
|
542
|
+
entity.add(new world_model_1.ReadableTrait({ text: configValue(trait, 'text') ?? '' }));
|
|
543
|
+
break;
|
|
544
|
+
case 'openable':
|
|
545
|
+
if (!entity.has(world_model_1.TraitType.OPENABLE))
|
|
546
|
+
entity.add(new world_model_1.OpenableTrait());
|
|
547
|
+
break;
|
|
548
|
+
case 'lockable':
|
|
549
|
+
if (!entity.has(world_model_1.TraitType.LOCKABLE))
|
|
550
|
+
entity.add(new world_model_1.LockableTrait({ keyId: configValue(trait, 'key') }));
|
|
551
|
+
break;
|
|
552
|
+
case 'switchable':
|
|
553
|
+
entity.add(new world_model_1.SwitchableTrait());
|
|
554
|
+
break;
|
|
555
|
+
case 'edible':
|
|
556
|
+
entity.add(new world_model_1.EdibleTrait());
|
|
557
|
+
break;
|
|
558
|
+
case 'light-source':
|
|
559
|
+
entity.add(new world_model_1.LightSourceTrait());
|
|
560
|
+
break;
|
|
561
|
+
case 'plural': {
|
|
562
|
+
const identity = entity.get(world_model_1.TraitType.IDENTITY);
|
|
563
|
+
if (identity)
|
|
564
|
+
identity.grammaticalNumber = 'plural';
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
case 'dark': {
|
|
568
|
+
if (kind !== 'room') {
|
|
569
|
+
throw new errors_1.LoadError(`\`dark\` applies to rooms only.`, trait.span);
|
|
570
|
+
}
|
|
571
|
+
break; // unconditional dark handled by the room builder
|
|
572
|
+
}
|
|
573
|
+
default: {
|
|
574
|
+
// `define trait` instances (Phase B): a data trait typed
|
|
575
|
+
// `chord.trait.<name>` whose fields are own properties (mutable
|
|
576
|
+
// via `set`, serialized with the world — AC-6-safe).
|
|
577
|
+
const def = this.ir.traits.find((t) => t.name === trait.name);
|
|
578
|
+
if (def) {
|
|
579
|
+
entity.add(new ChordDataTrait(state_keys_1.CHORD_TRAIT_PREFIX + def.name, this.traitFieldValues(def, trait)));
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
throw new errors_1.LoadError(`Trait \`${trait.name}\` is not declared (\`define trait ${trait.name}\`) and is not a v1 adjective.`, trait.span);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Initial values for a `define trait` instance: declared `starts`
|
|
589
|
+
* defaults overlaid by the composition's `with` config. Entity-name
|
|
590
|
+
* values (`with food the handful of feed`) resolve to IR entity ids.
|
|
591
|
+
*/
|
|
592
|
+
traitFieldValues(def, comp) {
|
|
593
|
+
const values = {};
|
|
594
|
+
for (const field of def.data) {
|
|
595
|
+
if (field.initial !== null)
|
|
596
|
+
values[field.name] = field.initial;
|
|
597
|
+
}
|
|
598
|
+
for (const setting of comp.config) {
|
|
599
|
+
if (setting.valueKind === 'name') {
|
|
600
|
+
const lower = setting.value.toLowerCase();
|
|
601
|
+
const target = this.ir.entities.find((e) => e.name.toLowerCase() === lower || e.aka.includes(lower));
|
|
602
|
+
if (!target) {
|
|
603
|
+
throw new errors_1.LoadError(`\`${setting.value}\` (config \`${setting.key}\`) names no entity.`, comp.span);
|
|
604
|
+
}
|
|
605
|
+
values[setting.key] = target.id;
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
values[setting.key] = setting.value;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return values;
|
|
612
|
+
}
|
|
613
|
+
applyContainerConfig(entity, kind) {
|
|
614
|
+
const maxItems = configValue(kind, 'max items');
|
|
615
|
+
const maxWeight = configValue(kind, 'max weight');
|
|
616
|
+
if (maxItems === undefined && maxWeight === undefined)
|
|
617
|
+
return;
|
|
618
|
+
const container = entity.get(world_model_1.TraitType.CONTAINER);
|
|
619
|
+
if (!container)
|
|
620
|
+
return;
|
|
621
|
+
container.capacity = {
|
|
622
|
+
...(maxItems !== undefined ? { maxItems: Number(maxItems) } : {}),
|
|
623
|
+
...(maxWeight !== undefined ? { maxWeight: Number(maxWeight) } : {}),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// -------------------------------------------------------------- helpers
|
|
627
|
+
requireWorldId(irId, at) {
|
|
628
|
+
const id = this.worldIds.get(irId);
|
|
629
|
+
if (!id) {
|
|
630
|
+
throw new errors_1.LoadError(`Entity \`${irId}\` is referenced before it exists in the world.`, at?.span);
|
|
631
|
+
}
|
|
632
|
+
return id;
|
|
633
|
+
}
|
|
634
|
+
/** Resolved default-locale text for a phrase key (single-variant read). */
|
|
635
|
+
/**
|
|
636
|
+
* Z2 (ADR-211): compile `{key}` strategy-phrase markers in this room's
|
|
637
|
+
* description prose onto ADR-209 storage. ATOMIC per room: every
|
|
638
|
+
* rewrite/entry/gate is computed first and applied only when the whole
|
|
639
|
+
* room compiled clean — a LoadError leaves the room untouched, never
|
|
640
|
+
* partial. Markers rewrite to `{snippet:key}`; variants populate
|
|
641
|
+
* `RoomTrait.snippets[key]` (`nothing` → `''`, strategy → selector via the
|
|
642
|
+
* Z5 table; a single-variant plain phrase compiles to a plain string
|
|
643
|
+
* entry); the phrase's `while` gate compiles — a presence condition on the
|
|
644
|
+
* marker's own room (`is here` / `is in <this room>`, non-negated) becomes
|
|
645
|
+
* `mentions`, anything else registers on the ADR-211 gate seam keyed
|
|
646
|
+
* `(roomId, marker)` (stdlib `registerSnippetGate` — in-memory, nothing
|
|
647
|
+
* serialized, re-registered every story load). Both description texts
|
|
648
|
+
* share one entry per marker (Z1/ADR-211 Q6: shared entries + counters).
|
|
649
|
+
*
|
|
650
|
+
* @param world the world being built (gate thunks close over it)
|
|
651
|
+
* @param irEntity the room's IR entity (presence-gate room identity)
|
|
652
|
+
* @param entity the built room entity
|
|
653
|
+
*/
|
|
654
|
+
compileRoomSnippets(world, irEntity, entity) {
|
|
655
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
656
|
+
const hatchNames = new Set(this.ir.hatches.map((h) => h.name));
|
|
657
|
+
const identity = entity.get(world_model_1.TraitType.IDENTITY);
|
|
658
|
+
const roomTrait = entity.get(world_model_1.TraitType.ROOM);
|
|
659
|
+
if (!roomTrait)
|
|
660
|
+
return;
|
|
661
|
+
const texts = [];
|
|
662
|
+
if (identity && typeof identity.description === 'string') {
|
|
663
|
+
texts.push({ value: identity.description, apply: (t) => void (identity.description = t) });
|
|
664
|
+
}
|
|
665
|
+
if (typeof roomTrait.initialDescription === 'string') {
|
|
666
|
+
texts.push({ value: roomTrait.initialDescription, apply: (t) => void (roomTrait.initialDescription = t) });
|
|
667
|
+
}
|
|
668
|
+
// Compute phase — nothing is applied until every marker compiled.
|
|
669
|
+
const entries = new Map();
|
|
670
|
+
const gates = [];
|
|
671
|
+
for (const slot of texts) {
|
|
672
|
+
for (const match of slot.value.matchAll(/\{([a-z][a-z0-9-]*)\}/g)) {
|
|
673
|
+
const marker = match[1];
|
|
674
|
+
if (marker === 'br' || hatchNames.has(marker) || entries.has(marker))
|
|
675
|
+
continue;
|
|
676
|
+
const phrase = table[marker];
|
|
677
|
+
if (!phrase)
|
|
678
|
+
continue; // not a declared phrase — stays literal prose
|
|
679
|
+
if (phrase.verbatim) {
|
|
680
|
+
// The analyzer already errors here (analysis.verbatim-marker);
|
|
681
|
+
// this is the loader's defensive half of the same contract.
|
|
682
|
+
throw new errors_1.LoadError(`\`{${marker}}\` in \`${irEntity.name}\` names a verbatim phrase — verbatim text cannot splice at a description marker.`, phrase.span);
|
|
683
|
+
}
|
|
684
|
+
const variantTexts = phrase.variants.map((v) => (v.text === 'nothing' ? '' : (0, text_1.withLineBreaks)(v.text)));
|
|
685
|
+
let mentions;
|
|
686
|
+
if (phrase.condition) {
|
|
687
|
+
const subject = presenceSubject(phrase.condition, irEntity.id);
|
|
688
|
+
if (subject) {
|
|
689
|
+
mentions = this.requireWorldId(subject, irEntity);
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
const condition = phrase.condition;
|
|
693
|
+
gates.push(() => (0, stdlib_1.registerSnippetGate)(entity.id, marker, () => this.evaluator.evalCondition(condition, { world })));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const selector = phrase.strategy ? runtime_1.STRATEGY_SELECTOR[phrase.strategy] : undefined;
|
|
697
|
+
let entry;
|
|
698
|
+
if (!selector && variantTexts.length === 1) {
|
|
699
|
+
// Single-variant plain phrase → plain string entry, never a Choice.
|
|
700
|
+
entry = mentions ? { text: variantTexts[0], mentions } : variantTexts[0];
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
entry = {
|
|
704
|
+
// A plain multi-variant phrase takes ADR-209's short-form
|
|
705
|
+
// default (cycling); strategy phrases carry their Z5 selector.
|
|
706
|
+
selector: selector ?? 'cycling',
|
|
707
|
+
texts: variantTexts,
|
|
708
|
+
...(mentions !== undefined ? { mentions } : {}),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
entries.set(marker, entry);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (entries.size === 0)
|
|
715
|
+
return;
|
|
716
|
+
// Apply phase — rewrite texts, populate the map, register the gates.
|
|
717
|
+
for (const slot of texts) {
|
|
718
|
+
let rewritten = slot.value;
|
|
719
|
+
for (const marker of entries.keys()) {
|
|
720
|
+
rewritten = rewritten.split(`{${marker}}`).join(`{snippet:${marker}}`);
|
|
721
|
+
}
|
|
722
|
+
if (rewritten !== slot.value)
|
|
723
|
+
slot.apply(rewritten);
|
|
724
|
+
}
|
|
725
|
+
roomTrait.snippets = { ...(roomTrait.snippets ?? {}), ...Object.fromEntries(entries) };
|
|
726
|
+
for (const register of gates)
|
|
727
|
+
register();
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Z3 (ADR-213 §2): register the one `disappeared` observer, when any
|
|
731
|
+
* entity authors the channel. On a successful removal: skip the player
|
|
732
|
+
* (out of the channel's scope), require an authored block, require the
|
|
733
|
+
* removal to be witnessed (the player's containing room equals the
|
|
734
|
+
* entity's last containing room), then ENQUEUE the phrase through the
|
|
735
|
+
* existing phrase-event path — never rendered inline from the observer.
|
|
736
|
+
* Unwitnessed removals enqueue nothing and consume nothing (D11).
|
|
737
|
+
* Orphaning never reaches here (the seam fires only in `removeEntity`).
|
|
738
|
+
*
|
|
739
|
+
* @param world the world being built (the observer closes over it)
|
|
740
|
+
*/
|
|
741
|
+
registerRemovalObserver(world) {
|
|
742
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
743
|
+
if (!this.ir.entities.some((e) => table[`${e.id}.disappeared`]))
|
|
744
|
+
return;
|
|
745
|
+
world.onEntityRemoved((entity, lastRoomId) => {
|
|
746
|
+
const playerId = world.getPlayer()?.id;
|
|
747
|
+
if (!playerId || entity.id === playerId)
|
|
748
|
+
return;
|
|
749
|
+
const irId = this.irIds.get(entity.id);
|
|
750
|
+
if (!irId || !table[`${irId}.disappeared`])
|
|
751
|
+
return;
|
|
752
|
+
if (lastRoomId === null)
|
|
753
|
+
return; // nowhere = nothing witnessable
|
|
754
|
+
const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
|
|
755
|
+
if (playerRoom !== lastRoomId)
|
|
756
|
+
return; // unwitnessed: nothing enqueued, nothing consumed
|
|
757
|
+
const event = this.runtime.channelEvent(irId, 'disappeared', world);
|
|
758
|
+
if (event)
|
|
759
|
+
this.runtime.enqueueChannelEvent(event);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Z3b: compile each entity's gated `detail` blocks. The two shipped trait
|
|
764
|
+
* shapes bind their fields directly — `while it is on` →
|
|
765
|
+
* `SwitchableTrait.detailWhenOn`, `while it is lit` →
|
|
766
|
+
* `LightSourceTrait.detailWhenLit` (both read by world-model's
|
|
767
|
+
* state-clauses registry). Any other condition joins the loader-owned
|
|
768
|
+
* provider: one marker trait per owner, ONE contributor registered per
|
|
769
|
+
* load (last-wins on the registry — the ADR-211/212 lifecycle), which
|
|
770
|
+
* evaluates the gate live at examine time.
|
|
771
|
+
*
|
|
772
|
+
* @param world the world being built (the provider closes over it)
|
|
773
|
+
*/
|
|
774
|
+
compileDetailChannels(world) {
|
|
775
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
776
|
+
const providerSpecs = new Map();
|
|
777
|
+
for (const irEntity of this.ir.entities) {
|
|
778
|
+
const worldId = this.worldIds.get(irEntity.id);
|
|
779
|
+
if (!worldId)
|
|
780
|
+
continue;
|
|
781
|
+
const entity = world.getEntity(worldId);
|
|
782
|
+
if (!entity)
|
|
783
|
+
continue;
|
|
784
|
+
for (let i = 1;; i++) {
|
|
785
|
+
const key = i === 1 ? `${irEntity.id}.detail` : `${irEntity.id}.detail.${i}`;
|
|
786
|
+
const phrase = table[key];
|
|
787
|
+
if (!phrase)
|
|
788
|
+
break;
|
|
789
|
+
if (!phrase.condition)
|
|
790
|
+
continue; // analyzer already errored (detail-unconditional)
|
|
791
|
+
const text = (0, text_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
|
|
792
|
+
const shape = detailTraitShape(phrase.condition, irEntity.id);
|
|
793
|
+
if (shape === 'on' && entity.has(world_model_1.TraitType.SWITCHABLE)) {
|
|
794
|
+
entity.get(world_model_1.TraitType.SWITCHABLE).detailWhenOn = text;
|
|
795
|
+
}
|
|
796
|
+
else if (shape === 'lit' && entity.has(world_model_1.TraitType.LIGHT_SOURCE)) {
|
|
797
|
+
entity.get(world_model_1.TraitType.LIGHT_SOURCE).detailWhenLit = text;
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
const specs = providerSpecs.get(worldId) ?? [];
|
|
801
|
+
specs.push({ irId: irEntity.id, condition: phrase.condition, text });
|
|
802
|
+
providerSpecs.set(worldId, specs);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (providerSpecs.size === 0)
|
|
807
|
+
return;
|
|
808
|
+
for (const worldId of providerSpecs.keys()) {
|
|
809
|
+
const entity = world.getEntity(worldId);
|
|
810
|
+
if (entity && !entity.has(ChordDetailTrait.type))
|
|
811
|
+
entity.add(new ChordDetailTrait());
|
|
812
|
+
}
|
|
813
|
+
(0, world_model_1.registerClauseContributor)(ChordDetailTrait.type, (entity) => {
|
|
814
|
+
const specs = providerSpecs.get(entity.id);
|
|
815
|
+
if (!specs)
|
|
816
|
+
return [];
|
|
817
|
+
return specs
|
|
818
|
+
.filter((spec) => this.evaluator.evalCondition(spec.condition, { world, it: spec.irId }))
|
|
819
|
+
.map((spec) => spec.text);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
phraseText(key) {
|
|
823
|
+
const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
|
|
824
|
+
if (!phrase) {
|
|
825
|
+
throw new errors_1.LoadError(`Phrase \`${key}\` is missing from the IR — the compiler gate should have caught this.`);
|
|
826
|
+
}
|
|
827
|
+
return (0, text_1.withLineBreaks)(phrase.variants[0]?.text ?? '');
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
exports.ChordStory = ChordStory;
|
|
831
|
+
/**
|
|
832
|
+
* The shipped trait-field shape of a `detail` gate: exactly `it is on` /
|
|
833
|
+
* `it is lit` on the owner, non-negated (Z3b/CP5') — else null and the
|
|
834
|
+
* loader-owned provider evaluates the condition live.
|
|
835
|
+
*
|
|
836
|
+
* @param cond the block's resolved gate
|
|
837
|
+
* @param ownerIrId the owning entity's IR id
|
|
838
|
+
* @returns 'on' | 'lit' | null
|
|
839
|
+
*/
|
|
840
|
+
function detailTraitShape(cond, ownerIrId) {
|
|
841
|
+
if (cond.kind !== 'predicate' || cond.pred !== 'is' || cond.negated)
|
|
842
|
+
return null;
|
|
843
|
+
const subjectIsOwner = cond.subject.kind === 'it' || (cond.subject.kind === 'entity' && cond.subject.id === ownerIrId);
|
|
844
|
+
if (!subjectIsOwner || cond.object.kind !== 'symbol')
|
|
845
|
+
return null;
|
|
846
|
+
if (cond.object.name === 'on')
|
|
847
|
+
return 'on';
|
|
848
|
+
if (cond.object.name === 'lit')
|
|
849
|
+
return 'lit';
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The presence-gate subject when `cond` is `<entity> is here` or
|
|
854
|
+
* `<entity> is in <this room>` (non-negated) — the two forms that compile to
|
|
855
|
+
* ADR-209 `mentions` at a marker site (Z2/AC-4/AC-11). Null for anything
|
|
856
|
+
* else (those register on the gate seam instead).
|
|
857
|
+
*
|
|
858
|
+
* @param cond the phrase's resolved header gate
|
|
859
|
+
* @param roomIrId the IR id of the room whose description hosts the marker
|
|
860
|
+
* @returns the subject's IR entity id, or null
|
|
861
|
+
*/
|
|
862
|
+
function presenceSubject(cond, roomIrId) {
|
|
863
|
+
if (cond.kind !== 'predicate' || cond.negated || cond.subject.kind !== 'entity')
|
|
864
|
+
return null;
|
|
865
|
+
if (cond.pred === 'is-here')
|
|
866
|
+
return cond.subject.id;
|
|
867
|
+
if (cond.pred === 'is-in' && cond.object.kind === 'entity' && cond.object.id === roomIrId) {
|
|
868
|
+
return cond.subject.id;
|
|
869
|
+
}
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
/** Chord direction word → world-model DirectionType. */
|
|
873
|
+
function toDirection(word, at) {
|
|
874
|
+
const dir = world_model_1.Direction[word.toUpperCase()];
|
|
875
|
+
if (!dir)
|
|
876
|
+
throw new errors_1.LoadError(`Unknown direction \`${word}\`.`, at?.span);
|
|
877
|
+
return dir;
|
|
878
|
+
}
|
|
879
|
+
/** `with capacity N` on a supporter kind. */
|
|
880
|
+
function supporterCapacity(kind) {
|
|
881
|
+
const capacity = configValue(kind, 'capacity');
|
|
882
|
+
return capacity !== undefined ? { maxItems: Number(capacity) } : {};
|
|
883
|
+
}
|
|
884
|
+
function configValue(comp, key) {
|
|
885
|
+
return comp.config.find((c) => c.key === key)?.value;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* The Language Provider template for an IR phrase. Single-variant phrases
|
|
889
|
+
* register their text (with `{br}` mapped to hard line breaks); verbatim
|
|
890
|
+
* and strategy phrases register a placeholder the runtime fills at emit
|
|
891
|
+
* time — a whitespace-exempt atom or a Choice atom (ADR-196).
|
|
892
|
+
*/
|
|
893
|
+
function templateFor(phrase) {
|
|
894
|
+
if (phrase.verbatim)
|
|
895
|
+
return '{verbatim:text}';
|
|
896
|
+
if (phrase.strategy === null && phrase.variants.length === 1)
|
|
897
|
+
return (0, text_1.withLineBreaks)(phrase.variants[0].text);
|
|
898
|
+
return '{variants}';
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* `define verb` → engine CustomVocabulary. Phase A supports the two-slot
|
|
902
|
+
* prepositional shape (`put (something) on (something)`) that maps onto an
|
|
903
|
+
* existing two-object action, matching the hand-written Cloak's PUT_ON
|
|
904
|
+
* registration.
|
|
905
|
+
*/
|
|
906
|
+
function toVocabularyVerb(verb) {
|
|
907
|
+
const words = verb.pattern.filter((p) => p.kind === 'word').map((p) => p.word);
|
|
908
|
+
const slots = verb.pattern.filter((p) => p.kind === 'slot').length;
|
|
909
|
+
const base = words[0];
|
|
910
|
+
const preposition = words[1];
|
|
911
|
+
const KNOWN = { 'put on': 'PUT_ON' };
|
|
912
|
+
const actionId = KNOWN[`${base} ${preposition ?? ''}`.trim()];
|
|
913
|
+
if (!actionId || slots !== 2) {
|
|
914
|
+
throw new errors_1.LoadError(`\`define verb ${verb.verbs.join(' or ')}\` maps to \`${words.join(' ')}\`, which the Phase A loader cannot register.`);
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
actionId,
|
|
918
|
+
verbs: verb.verbs,
|
|
919
|
+
pattern: 'VERB NOUN PREP NOUN',
|
|
920
|
+
prepositions: [preposition],
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
//# sourceMappingURL=loader.js.map
|