@sharpee/story-loader 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/chain-map.d.ts +40 -0
- package/chain-map.d.ts.map +1 -0
- package/chain-map.js +39 -0
- package/chain-map.js.map +1 -0
- package/evaluator.d.ts +16 -0
- package/evaluator.d.ts.map +1 -1
- package/evaluator.js +56 -21
- package/evaluator.js.map +1 -1
- package/event-contract.d.ts +18 -0
- package/event-contract.d.ts.map +1 -1
- package/event-contract.js +28 -1
- package/event-contract.js.map +1 -1
- package/event-id-map.d.ts +42 -0
- package/event-id-map.d.ts.map +1 -0
- package/event-id-map.js +62 -0
- package/event-id-map.js.map +1 -0
- package/extension-registry.d.ts +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 +88 -5
- package/loader.d.ts.map +1 -1
- package/loader.js +807 -119
- package/loader.js.map +1 -1
- package/message-alias-map.d.ts +25 -0
- package/message-alias-map.d.ts.map +1 -0
- package/message-alias-map.js +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 +139 -19
- package/runtime.d.ts.map +1 -1
- package/runtime.js +692 -166
- package/runtime.js.map +1 -1
package/runtime.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ChordRuntime = exports.ChordBehaviorTrait = exports.STRATEGY_SELECTOR = void 0;
|
|
4
|
+
const chord_1 = require("@sharpee/chord");
|
|
5
|
+
const engine_1 = require("@sharpee/engine");
|
|
6
|
+
const phrasebook_data_js_1 = require("./phrasebook-data.js");
|
|
4
7
|
const world_model_1 = require("@sharpee/world-model");
|
|
5
8
|
const stdlib_1 = require("@sharpee/stdlib");
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
9
|
+
const errors_js_1 = require("./errors.js");
|
|
10
|
+
const state_keys_js_1 = require("./state-keys.js");
|
|
11
|
+
const text_js_1 = require("./text.js");
|
|
12
|
+
const hatch_context_js_1 = require("./hatch-context.js");
|
|
13
|
+
const event_contract_js_1 = require("./event-contract.js");
|
|
14
|
+
const event_id_map_js_1 = require("./event-id-map.js");
|
|
15
|
+
const message_alias_map_js_1 = require("./message-alias-map.js");
|
|
11
16
|
/**
|
|
12
17
|
* Chord strategy adverb → phrase-algebra Choice selector (ADR-196).
|
|
13
18
|
* The Z5 table (ADR-211 Decision 4): adverbs mirror the selectors 1:1;
|
|
@@ -28,6 +33,11 @@ class ChordBehaviorTrait {
|
|
|
28
33
|
type = ChordBehaviorTrait.type;
|
|
29
34
|
}
|
|
30
35
|
exports.ChordBehaviorTrait = ChordBehaviorTrait;
|
|
36
|
+
/**
|
|
37
|
+
* The two gerunds a topic table serves (ADR-239 D1 — one table, ask and
|
|
38
|
+
* tell alike). The table rides these actions' interceptor dispatch.
|
|
39
|
+
*/
|
|
40
|
+
const TOPIC_GERUNDS = ['asking', 'telling'];
|
|
31
41
|
class ChordRuntime {
|
|
32
42
|
ir;
|
|
33
43
|
host;
|
|
@@ -58,11 +68,19 @@ class ChordRuntime {
|
|
|
58
68
|
return;
|
|
59
69
|
// Event clauses (`after entering it`) bind to the event stream per
|
|
60
70
|
// the selector contract — the ownership package's replacement for
|
|
61
|
-
// floating `when` rules.
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// floating `when` rules. A REGION owner re-homes the verb onto the
|
|
72
|
+
// crossing events (ADR-236 D6): entering → region_entered, leaving
|
|
73
|
+
// → region_exited.
|
|
74
|
+
const trigger = this.eventTriggerFor(entity, clause);
|
|
75
|
+
if (trigger) {
|
|
76
|
+
this.bindEventClause(world, entity, clause, clauseIndex, trigger);
|
|
64
77
|
return;
|
|
65
78
|
}
|
|
79
|
+
if (event_contract_js_1.REGION_EVENT_TRIGGERS[clause.action]) {
|
|
80
|
+
// `leaving` exists only as a region crossing reaction (D6) — on
|
|
81
|
+
// any other owner it would silently never fire. Refuse at load.
|
|
82
|
+
throw new errors_js_1.LoadError(`\`${clause.clauseKind} ${clause.action} it\` — \`${clause.action}\` is a region crossing reaction (ADR-236), and \`${entity.name}\` is not a region. Put the clause on the region block whose boundary it reacts to.`, clause.span);
|
|
83
|
+
}
|
|
66
84
|
// D5 fail-fast (ADR-228): only bind clauses something will consult.
|
|
67
85
|
if (!this.isConsultedGerund(clause.action)) {
|
|
68
86
|
if (this.isDispatchAction(clause.action)) {
|
|
@@ -72,7 +90,7 @@ class ChordRuntime {
|
|
|
72
90
|
if (clause.clauseKind === 'after')
|
|
73
91
|
return;
|
|
74
92
|
// …but an entity `on` clause has no dispatch surface at all.
|
|
75
|
-
throw new
|
|
93
|
+
throw new errors_js_1.LoadError(`\`on ${clause.action} it\` — \`${clause.action}\` is a Chord dispatch action, and entity \`on\` clauses never fire on the dispatch path. Move the clause into a trait (\`define trait … on ${clause.action} it\`) and compose the trait, or react with \`after ${clause.action} it\`.`, clause.span);
|
|
76
94
|
}
|
|
77
95
|
throw this.deadGerundError(clause);
|
|
78
96
|
}
|
|
@@ -82,21 +100,47 @@ class ChordRuntime {
|
|
|
82
100
|
byAction.set(clause.action, list);
|
|
83
101
|
});
|
|
84
102
|
}
|
|
103
|
+
// ADR-239: topic tables ride the asking/telling dispatch. Every table
|
|
104
|
+
// owner gets an arm — with or without a catch-all clause (D5: with no
|
|
105
|
+
// catch-all declared, a miss simply returns {} and the action's
|
|
106
|
+
// unconditional unknown_topic/not_interested default stands).
|
|
107
|
+
for (const gerund of TOPIC_GERUNDS) {
|
|
108
|
+
for (const entity of this.ir.entities) {
|
|
109
|
+
if (!(entity.topics ?? []).length)
|
|
110
|
+
continue;
|
|
111
|
+
const list = byAction.get(gerund) ?? [];
|
|
112
|
+
if (!list.some((c) => c.entity.id === entity.id)) {
|
|
113
|
+
this.prepareTopicTarget(world, entity);
|
|
114
|
+
list.push({ entity, clause: null });
|
|
115
|
+
byAction.set(gerund, list);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
85
119
|
for (const [action, clauses] of byAction) {
|
|
86
|
-
world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(clauses));
|
|
120
|
+
world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(action, clauses));
|
|
87
121
|
}
|
|
88
122
|
// Phase B: `define trait` clauses register per TRAIT TYPE — capability
|
|
89
123
|
// behaviors for dispatch verbs, interceptors for standard-semantics
|
|
90
124
|
// actions (§5.4 routing recorded on the IR by the analyzer).
|
|
91
125
|
for (const trait of this.ir.traits) {
|
|
92
|
-
const traitType =
|
|
126
|
+
const traitType = state_keys_js_1.CHORD_TRAIT_PREFIX + trait.name;
|
|
127
|
+
const interceptorClauses = new Map();
|
|
128
|
+
const capabilityActions = new Set();
|
|
93
129
|
for (const clause of trait.onClauses) {
|
|
94
130
|
if (clause.binding === 'every-turn')
|
|
95
131
|
continue; // scheduler phase (plan phase 5)
|
|
96
132
|
if (clause.binding === 'role') {
|
|
97
|
-
throw new
|
|
133
|
+
throw new errors_js_1.LoadError(`Role-bound trait clauses (\`on ${clause.action} anything as the ${clause.role}\`) are not wired yet — the standard-action role path is post-Zoo scope.`, clause.span);
|
|
98
134
|
}
|
|
99
135
|
if (clause.routing === 'capability') {
|
|
136
|
+
// The capability registry is (traitType, action)-keyed and
|
|
137
|
+
// last-wins: a second clause for the same dispatch action would
|
|
138
|
+
// silently OVERWRITE the first. Refuse legibly (never-guess)
|
|
139
|
+
// until the capability pair is wired.
|
|
140
|
+
if (capabilityActions.has(clause.action)) {
|
|
141
|
+
throw new errors_js_1.LoadError(`Trait \`${trait.name}\` declares more than one clause for the dispatch action \`${clause.action}\` — the capability registry holds one behavior per (trait, action), so the second clause could never fire. Merge the bodies into one clause.`, clause.span);
|
|
142
|
+
}
|
|
143
|
+
capabilityActions.add(clause.action);
|
|
100
144
|
world.registerCapabilityBehavior(traitType, `chord.action.${clause.action}`, this.buildCapabilityBehavior(trait.name, clause));
|
|
101
145
|
}
|
|
102
146
|
else {
|
|
@@ -104,30 +148,197 @@ class ChordRuntime {
|
|
|
104
148
|
// interceptor path, so its gerund must name a consulted action.
|
|
105
149
|
if (!this.isConsultedGerund(clause.action))
|
|
106
150
|
throw this.deadGerundError(clause);
|
|
107
|
-
|
|
151
|
+
const list = interceptorClauses.get(clause.action) ?? [];
|
|
152
|
+
list.push(clause);
|
|
153
|
+
interceptorClauses.set(clause.action, list);
|
|
108
154
|
}
|
|
109
155
|
}
|
|
156
|
+
// One MERGED interceptor per (trait, action) — the D3 `on`/`after`
|
|
157
|
+
// pair both fire (the idempotent registry would otherwise keep only
|
|
158
|
+
// the last-registered clause, silently).
|
|
159
|
+
for (const [action, actionClauses] of interceptorClauses) {
|
|
160
|
+
world.registerActionInterceptor(traitType, `if.action.${action}`, this.mergeArms(actionClauses.map((clause, index) => this.buildTraitInterceptor(clause, `${trait.name}.${action}.${clause.clauseKind}.${index}`))));
|
|
161
|
+
}
|
|
110
162
|
}
|
|
111
|
-
// Derived properties (`dark while`,
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
163
|
+
// Derived properties (`dark while`, blocked exits) — ADR-240: registered
|
|
164
|
+
// as live evaluators consulted at point of use. Nothing is stamped and
|
|
165
|
+
// nothing recomputes: mutations are instant, every reader sees current
|
|
166
|
+
// truth (the former eleven-event recompute trigger list is gone).
|
|
167
|
+
this.registerDerivedEvaluators(world);
|
|
168
|
+
// Phrasebooks (ADR-250 D4): one evaluator per book-covered key the
|
|
169
|
+
// story does not define — same ADR-240 seam, resolved at render time.
|
|
170
|
+
this.registerPhrasebookEvaluators(world);
|
|
171
|
+
// Message overrides (ADR-255 D6): standard-action message baselines,
|
|
172
|
+
// registered on the same seam so they beat the platform default but lose
|
|
173
|
+
// to per-entity/on-clause message ids.
|
|
174
|
+
this.registerMessageOverrideEvaluators(world);
|
|
175
|
+
}
|
|
176
|
+
/** Resolved books cache (built once per runtime — see resolvedBooks). */
|
|
177
|
+
books = null;
|
|
178
|
+
/**
|
|
179
|
+
* The story's phrasebooks in arbitration order, entries resolved:
|
|
180
|
+
* `define`d books carry their entries in the IR; `use`d books resolve
|
|
181
|
+
* from the packaged-data registry with manifest-key conformance (ADR-250
|
|
182
|
+
* D3 — LoadError on a missing book or a key mismatch), plus the D1 key
|
|
183
|
+
* rules the story compiler never saw the packaged data pass through.
|
|
184
|
+
*/
|
|
185
|
+
resolvedBooks() {
|
|
186
|
+
if (this.books)
|
|
187
|
+
return this.books;
|
|
188
|
+
this.books = this.ir.phrasebooks.map((book) => {
|
|
189
|
+
if (book.source === 'define') {
|
|
190
|
+
return { name: book.name, condition: book.condition ?? null, entries: book.entries ?? {} };
|
|
191
|
+
}
|
|
192
|
+
const data = phrasebook_data_js_1.PHRASEBOOK_DATA.get(book.name);
|
|
193
|
+
if (!data) {
|
|
194
|
+
throw new errors_js_1.LoadError(`Phrasebook \`${book.name}\` is not in the load-time data registry — the compile-time manifest knows the name, the runtime has no entries for it.`);
|
|
195
|
+
}
|
|
196
|
+
const manifestKeys = [...(chord_1.PHRASEBOOK_REGISTRY.get(book.name)?.keys ?? [])].sort();
|
|
197
|
+
const dataKeys = Object.keys(data.entries).sort();
|
|
198
|
+
if (manifestKeys.join('') !== dataKeys.join('')) {
|
|
199
|
+
throw new errors_js_1.LoadError(`Phrasebook \`${book.name}\`: manifest keys [${manifestKeys.join(', ')}] and data keys [${dataKeys.join(', ')}] disagree.`);
|
|
200
|
+
}
|
|
201
|
+
for (const key of dataKeys) {
|
|
202
|
+
if (key.includes('.')) {
|
|
203
|
+
throw new errors_js_1.LoadError(`Phrasebook \`${book.name}\`: \`${key}\` is a dotted platform ID — books voice story keys only (ADR-250 D1).`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { name: book.name, condition: book.condition ?? null, entries: data.entries };
|
|
207
|
+
});
|
|
208
|
+
return this.books;
|
|
209
|
+
}
|
|
210
|
+
/** Book entries covering a key, in arbitration order (emit-time staging). */
|
|
211
|
+
bookEntriesFor(key) {
|
|
212
|
+
return this.resolvedBooks().flatMap((b) => (b.entries[key] ? [b.entries[key]] : []));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* ADR-250 D4.2: register ONE evaluator per key that some book covers and
|
|
216
|
+
* the story does NOT define — story-beats-book is decided here,
|
|
217
|
+
* statically, so a story-defined key never pays predicate evaluation.
|
|
218
|
+
* The key convention (`phrasebook.template.<key>`) is built by the
|
|
219
|
+
* engine's read point (`phrasebookTemplateKey`) and here — nowhere else.
|
|
220
|
+
*/
|
|
221
|
+
registerPhrasebookEvaluators(world) {
|
|
222
|
+
const books = this.resolvedBooks();
|
|
223
|
+
if (books.length === 0)
|
|
224
|
+
return;
|
|
225
|
+
const storyTable = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
226
|
+
const covered = new Set();
|
|
227
|
+
for (const book of books) {
|
|
228
|
+
for (const key of Object.keys(book.entries)) {
|
|
229
|
+
if (!storyTable[key])
|
|
230
|
+
covered.add(key);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const key of covered) {
|
|
234
|
+
world.registerEvaluator((0, engine_1.phrasebookTemplateKey)(key), (w) => this.resolvePhrasebook(key, w));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The evaluator body: first book in declaration order whose predicate
|
|
239
|
+
* holds AND that covers the key supplies it (ADR-245 D3 arbitration).
|
|
240
|
+
* Derivation mirrors registered phrases — verbatim/single/multi-variant
|
|
241
|
+
* templates and a Choice atom keyed `phrasebook.<book>` / key so
|
|
242
|
+
* cycling/first-time/sticky counters stay per (book, key) (ADR-250 D5)
|
|
243
|
+
* — keeping every Chord IR shape loader-side (ADR-210 direction rule).
|
|
244
|
+
*/
|
|
245
|
+
resolvePhrasebook(key, world) {
|
|
246
|
+
for (const book of this.resolvedBooks()) {
|
|
247
|
+
const entry = book.entries[key];
|
|
248
|
+
if (!entry)
|
|
249
|
+
continue;
|
|
250
|
+
if (book.condition && !this.evaluator.evalCondition(book.condition, { world }))
|
|
251
|
+
continue;
|
|
252
|
+
const { template, params } = this.derivePhraseTemplate(entry, `phrasebook.${book.name}`, key);
|
|
253
|
+
return { book: book.name, key, template, ...(Object.keys(params).length > 0 ? { params } : {}) };
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Derive the render-time template + bound params for an IR phrase body,
|
|
259
|
+
* shared by phrasebook resolution and ADR-255 message overrides: a verbatim
|
|
260
|
+
* or single-variant phrase becomes its literal template; a strategy/multi
|
|
261
|
+
* phrase becomes `{variants}` plus a Choice atom keyed by (counterEntityId,
|
|
262
|
+
* messageKey) so cycling/first-time/sticky counters stay per source+key.
|
|
263
|
+
*/
|
|
264
|
+
derivePhraseTemplate(entry, counterEntityId, messageKey) {
|
|
265
|
+
const params = {};
|
|
266
|
+
let template;
|
|
267
|
+
if (entry.verbatim) {
|
|
268
|
+
template = '{verbatim:text}';
|
|
269
|
+
params.text = entry.variants[0]?.text ?? '';
|
|
270
|
+
}
|
|
271
|
+
else if (entry.strategy === null && entry.variants.length === 1) {
|
|
272
|
+
template = (0, text_js_1.withLineBreaks)(entry.variants[0].text);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
template = '{variants}';
|
|
276
|
+
if (entry.strategy) {
|
|
277
|
+
const choice = {
|
|
278
|
+
kind: 'choice',
|
|
279
|
+
alternatives: entry.variants.map((v) => ({ kind: 'literal', text: (0, text_js_1.withLineBreaks)(v.text) })),
|
|
280
|
+
selector: exports.STRATEGY_SELECTOR[entry.strategy],
|
|
281
|
+
entityId: counterEntityId,
|
|
282
|
+
messageKey,
|
|
283
|
+
};
|
|
284
|
+
params.variants = choice;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { template, params };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* ADR-255 D6: register one evaluator per overridden standard-action message,
|
|
291
|
+
* on the SAME phrasebook resolution seam (`phrasebook.template.<id>`) the
|
|
292
|
+
* engine consults before the platform default — so an `override message`
|
|
293
|
+
* sets the story-wide baseline (with full strategy/cycling parity) while a
|
|
294
|
+
* per-entity phrase or on-clause refusal, which emit their own message ids,
|
|
295
|
+
* still win. The alias is resolved to its dotted `if.action.*` id here, on
|
|
296
|
+
* the loader side (Interface Contract 3); the alias never reaches the engine.
|
|
297
|
+
*/
|
|
298
|
+
registerMessageOverrideEvaluators(world) {
|
|
299
|
+
const table = this.ir.messageOverrides.locales[this.ir.messageOverrides.defaultLocale] ?? {};
|
|
300
|
+
for (const [alias, entry] of Object.entries(table)) {
|
|
301
|
+
const messageId = (0, message_alias_map_js_1.aliasToActionMessageId)(alias);
|
|
302
|
+
if (!messageId)
|
|
303
|
+
continue; // analyzer already rejected unknown aliases
|
|
304
|
+
world.registerEvaluator((0, engine_1.phrasebookTemplateKey)(messageId), () => {
|
|
305
|
+
if (entry.condition && !this.evaluator.evalCondition(entry.condition, { world }))
|
|
306
|
+
return undefined;
|
|
307
|
+
const { template, params } = this.derivePhraseTemplate(entry, 'message-override', messageId);
|
|
308
|
+
return { book: 'message-override', key: messageId, template, ...(Object.keys(params).length > 0 ? { params } : {}) };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* ADR-240 D2/D3: register every derived property as a named world-evaluator.
|
|
314
|
+
* `dark while` rooms register on `dark.<roomId>`; EVERY blocked exit —
|
|
315
|
+
* conditional or not (a constant-true predicate) — registers on
|
|
316
|
+
* `exit.blocked.<roomId>.<direction>`, with its refusal message on
|
|
317
|
+
* `exit.message.*` resolved AT REFUSAL TIME (phrase strategies vary per
|
|
318
|
+
* attempt). Registration is idempotent per world; re-binding re-registers.
|
|
319
|
+
*/
|
|
320
|
+
registerDerivedEvaluators(world) {
|
|
321
|
+
for (const { entity, condition } of this.derivedDarkRooms()) {
|
|
322
|
+
const worldId = this.host.entityId(entity.id);
|
|
323
|
+
if (!worldId)
|
|
324
|
+
continue;
|
|
325
|
+
world.registerEvaluator((0, world_model_1.darkKey)(worldId), (w) => this.evaluator.evalCondition(condition, { world: w }));
|
|
326
|
+
}
|
|
327
|
+
for (const irEntity of this.ir.entities) {
|
|
328
|
+
if (irEntity.blockedExits.length === 0)
|
|
329
|
+
continue;
|
|
330
|
+
const worldId = this.host.entityId(irEntity.id);
|
|
331
|
+
if (!worldId)
|
|
332
|
+
continue;
|
|
333
|
+
for (const blocked of irEntity.blockedExits) {
|
|
334
|
+
const direction = world_model_1.Direction[blocked.direction.toUpperCase()];
|
|
335
|
+
if (!direction)
|
|
336
|
+
continue;
|
|
337
|
+
const condition = blocked.condition;
|
|
338
|
+
world.registerEvaluator((0, stdlib_1.exitBlockedKey)(worldId, direction), condition
|
|
339
|
+
? (w) => this.evaluator.evalCondition(condition, { world: w, it: irEntity.id })
|
|
340
|
+
: () => true);
|
|
341
|
+
world.registerEvaluator((0, stdlib_1.exitMessageKey)(worldId, direction), (w) => this.blockedPhraseText(blocked.phraseKey, w));
|
|
131
342
|
}
|
|
132
343
|
}
|
|
133
344
|
}
|
|
@@ -159,28 +370,32 @@ class ChordRuntime {
|
|
|
159
370
|
deadGerundError(clause) {
|
|
160
371
|
const phrase = `${clause.clauseKind} ${clause.action} it`;
|
|
161
372
|
if (clause.action === 'lowering' || clause.action === 'raising') {
|
|
162
|
-
return new
|
|
373
|
+
return new errors_js_1.LoadError(`\`${phrase}\` — \`${clause.action}\` is a full-delegation capability action by design (ADR-118): the standard action never consults interceptors. Use a capability behavior or a Chord dispatch action (\`define action ${clause.action}\`) instead.`, clause.span);
|
|
163
374
|
}
|
|
164
|
-
return new
|
|
375
|
+
return new errors_js_1.LoadError(`\`${phrase}\` — no standard action consults \`if.action.${clause.action}\`, so this clause would never fire. Check the action word's spelling, or create the verb with \`define action ${clause.action}\`.`, clause.span);
|
|
165
376
|
}
|
|
166
377
|
// ---------------------------------------------------------- event clauses
|
|
167
378
|
/**
|
|
168
|
-
* Bind an event clause (`after entering it` on a room) to its
|
|
169
|
-
* event per the selector contract — the ownership package's
|
|
170
|
-
* for floating `when` rules: the same firing semantics,
|
|
171
|
-
* entity the event is about.
|
|
379
|
+
* Bind an event clause (`after entering it` on a room or region) to its
|
|
380
|
+
* trigger event per the selector contract — the ownership package's
|
|
381
|
+
* replacement for floating `when` rules: the same firing semantics,
|
|
382
|
+
* owned by the entity the event is about.
|
|
172
383
|
*/
|
|
173
|
-
bindEventClause(world, entity, clause, clauseIndex) {
|
|
174
|
-
const trigger = event_contract_1.EVENT_TRIGGERS[clause.action];
|
|
384
|
+
bindEventClause(world, entity, clause, clauseIndex, trigger) {
|
|
175
385
|
const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
|
|
176
386
|
world.chainEvent(trigger, (event, w) => this.fireEventClause(entity, clause, key, event, w), { key });
|
|
177
387
|
}
|
|
388
|
+
/** The clause's trigger event type by owner kind, or undefined for non-event clauses. */
|
|
389
|
+
eventTriggerFor(entity, clause) {
|
|
390
|
+
const isRegionOwner = entity.kinds.some((k) => k.name === 'region');
|
|
391
|
+
return isRegionOwner ? event_contract_js_1.REGION_EVENT_TRIGGERS[clause.action] : event_contract_js_1.EVENT_TRIGGERS[clause.action];
|
|
392
|
+
}
|
|
178
393
|
/** Test/debug entry: run every event clause bound to this event type. */
|
|
179
394
|
fireEventClauses(world, event) {
|
|
180
395
|
const out = [];
|
|
181
396
|
for (const entity of this.ir.entities) {
|
|
182
397
|
entity.onClauses.forEach((clause, clauseIndex) => {
|
|
183
|
-
if (clause.binding === 'every-turn' ||
|
|
398
|
+
if (clause.binding === 'every-turn' || this.eventTriggerFor(entity, clause) !== event.type)
|
|
184
399
|
return;
|
|
185
400
|
const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
|
|
186
401
|
const produced = this.fireEventClause(entity, clause, key, event, world);
|
|
@@ -191,15 +406,24 @@ class ChordRuntime {
|
|
|
191
406
|
return out;
|
|
192
407
|
}
|
|
193
408
|
fireEventClause(entity, clause, key, event, world) {
|
|
194
|
-
// The clause is about its owner
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
409
|
+
// The clause is about its owner. Region owners (ADR-236 D6): the
|
|
410
|
+
// crossing event names which boundary was crossed — fire only for this
|
|
411
|
+
// region's own boundary (the emitter's getRegionCrossings already made
|
|
412
|
+
// parent reactions crossing-accurate; no transitive widening here).
|
|
413
|
+
if (entity.kinds.some((k) => k.name === 'region')) {
|
|
414
|
+
if ((0, event_contract_js_1.crossingRegionId)(event.data) !== this.host.entityId(entity.id))
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
else if (clause.action === 'entering' && (0, event_contract_js_1.enteringDestination)(event.data) !== this.host.entityId(entity.id)) {
|
|
418
|
+
// Room/enterable owners: `after entering it` fires when the
|
|
419
|
+
// movement's destination IS the owner — read through the AC-9 payload
|
|
420
|
+
// guard, never a blind cast (the stdlib event is a foreign surface).
|
|
198
421
|
return null;
|
|
422
|
+
}
|
|
199
423
|
const ctx = { world, it: entity.id };
|
|
200
424
|
if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
|
|
201
425
|
return null;
|
|
202
|
-
const occKey =
|
|
426
|
+
const occKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + key;
|
|
203
427
|
const occurrence = (world.getStateValue(occKey) ?? 0) + 1;
|
|
204
428
|
if (clause.once && occurrence > 1)
|
|
205
429
|
return null; // `, once` — one lifetime firing (D5)
|
|
@@ -213,10 +437,10 @@ class ChordRuntime {
|
|
|
213
437
|
prepareOnClauseTarget(world, entity, clause) {
|
|
214
438
|
const worldId = this.host.entityId(entity.id);
|
|
215
439
|
if (!worldId)
|
|
216
|
-
throw new
|
|
440
|
+
throw new errors_js_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, clause.span);
|
|
217
441
|
const target = world.getEntity(worldId);
|
|
218
442
|
if (!target)
|
|
219
|
-
throw new
|
|
443
|
+
throw new errors_js_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, clause.span);
|
|
220
444
|
if (!target.has(ChordBehaviorTrait.type)) {
|
|
221
445
|
target.add(new ChordBehaviorTrait());
|
|
222
446
|
}
|
|
@@ -225,17 +449,106 @@ class ChordRuntime {
|
|
|
225
449
|
target.add(new world_model_1.ReadableTrait({ text: '' }));
|
|
226
450
|
}
|
|
227
451
|
}
|
|
452
|
+
/** Mark a topic-table owner so interceptor resolution finds it (no clause needed). */
|
|
453
|
+
prepareTopicTarget(world, entity) {
|
|
454
|
+
const worldId = this.host.entityId(entity.id);
|
|
455
|
+
if (!worldId)
|
|
456
|
+
throw new errors_js_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, entity.span);
|
|
457
|
+
const target = world.getEntity(worldId);
|
|
458
|
+
if (!target)
|
|
459
|
+
throw new errors_js_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, entity.span);
|
|
460
|
+
if (!target.has(ChordBehaviorTrait.type)) {
|
|
461
|
+
target.add(new ChordBehaviorTrait());
|
|
462
|
+
}
|
|
463
|
+
}
|
|
228
464
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
465
|
+
* Per-clause consultation state. Two live arms on one owner (ratchet D3's
|
|
466
|
+
* `on` + `after` pair) share ONE InterceptorSharedData bag per firing —
|
|
467
|
+
* each clause keeps its skip/occurrence/decision state in its own
|
|
468
|
+
* namespaced sub-bag so the arms never clobber each other.
|
|
232
469
|
*/
|
|
233
|
-
|
|
470
|
+
clauseBag(data, ns) {
|
|
471
|
+
const key = `chord.arm.${ns}`;
|
|
472
|
+
let bag = data[key];
|
|
473
|
+
if (!bag) {
|
|
474
|
+
bag = {};
|
|
475
|
+
data[key] = bag;
|
|
476
|
+
}
|
|
477
|
+
return bag;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Merge one owner's clause interceptors into a single arm, in declaration
|
|
481
|
+
* order (the ratchet D3 contract, previously broken by first-match arm
|
|
482
|
+
* routing — an `on`/`after` pair on the same owner+gerund silently
|
|
483
|
+
* shadowed the second clause): the first refusal wins preValidate; every
|
|
484
|
+
* arm's postValidate/postExecute runs (own namespaced state); postReport
|
|
485
|
+
* merges — the first `on` override wins (only `on` clauses override),
|
|
486
|
+
* every arm's emits APPEND (the `after` half of D3).
|
|
487
|
+
*/
|
|
488
|
+
mergeArms(arms) {
|
|
489
|
+
if (arms.length === 1)
|
|
490
|
+
return arms[0];
|
|
491
|
+
return {
|
|
492
|
+
preValidate(target, world, actorId, data) {
|
|
493
|
+
for (const arm of arms) {
|
|
494
|
+
const veto = arm.preValidate?.(target, world, actorId, data);
|
|
495
|
+
if (veto)
|
|
496
|
+
return veto;
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
},
|
|
500
|
+
postValidate(target, world, actorId, data) {
|
|
501
|
+
for (const arm of arms)
|
|
502
|
+
arm.postValidate?.(target, world, actorId, data);
|
|
503
|
+
return null;
|
|
504
|
+
},
|
|
505
|
+
postExecute(target, world, actorId, data) {
|
|
506
|
+
for (const arm of arms)
|
|
507
|
+
arm.postExecute?.(target, world, actorId, data);
|
|
508
|
+
},
|
|
509
|
+
postReport(target, world, actorId, data) {
|
|
510
|
+
const merged = {};
|
|
511
|
+
const emit = [];
|
|
512
|
+
for (const arm of arms) {
|
|
513
|
+
const result = arm.postReport?.(target, world, actorId, data) ?? {};
|
|
514
|
+
if (result.override && !merged.override)
|
|
515
|
+
merged.override = result.override;
|
|
516
|
+
if (result.emit)
|
|
517
|
+
emit.push(...result.emit);
|
|
518
|
+
}
|
|
519
|
+
if (emit.length)
|
|
520
|
+
merged.emit = emit;
|
|
521
|
+
return merged;
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* One interceptor per action: each hook forwards to the arm whose entity
|
|
527
|
+
* is the action's target. An owner's arm is the D3-merged composite of
|
|
528
|
+
* ALL its clauses for this action (each clause keeps its own namespaced
|
|
529
|
+
* occurrence keys and decision snapshots). On the topic gerunds
|
|
530
|
+
* (asking/telling, ADR-239) a table owner's arm consults its declared
|
|
531
|
+
* topic table first; the merged clause composite serves as the
|
|
532
|
+
* catch-all, firing only on a table miss (D5).
|
|
533
|
+
*/
|
|
534
|
+
buildDispatchingInterceptor(action, clauses) {
|
|
234
535
|
const runtime = this;
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
536
|
+
const isTopicAction = TOPIC_GERUNDS.includes(action);
|
|
537
|
+
const byEntity = new Map();
|
|
538
|
+
for (const { entity, clause } of clauses) {
|
|
539
|
+
const group = byEntity.get(entity.id) ?? { entity, entityClauses: [] };
|
|
540
|
+
if (clause)
|
|
541
|
+
group.entityClauses.push(clause);
|
|
542
|
+
byEntity.set(entity.id, group);
|
|
543
|
+
}
|
|
544
|
+
const arms = [...byEntity.values()].map(({ entity, entityClauses }) => {
|
|
545
|
+
const built = entityClauses.map((clause, index) => this.buildInterceptor(entity, clause, `${entity.id}.${action}.${clause.clauseKind}.${index}`));
|
|
546
|
+
const catchAll = built.length ? this.mergeArms(built) : undefined;
|
|
547
|
+
const interceptor = isTopicAction && (entity.topics ?? []).length
|
|
548
|
+
? this.buildTopicArm(entity, catchAll)
|
|
549
|
+
: catchAll ?? {};
|
|
550
|
+
return { entity, interceptor };
|
|
551
|
+
});
|
|
239
552
|
const armFor = (target) => arms.find((a) => runtime.host.entityId(a.entity.id) === target.id)?.interceptor;
|
|
240
553
|
return {
|
|
241
554
|
preValidate(target, world, actorId, data) {
|
|
@@ -259,15 +572,15 @@ class ChordRuntime {
|
|
|
259
572
|
* win/lose → postReport. An `on` clause's first phrase OVERRIDES the
|
|
260
573
|
* primary message; an `after` clause's phrases APPEND (D3).
|
|
261
574
|
*/
|
|
262
|
-
buildInterceptor(entity, clause) {
|
|
575
|
+
buildInterceptor(entity, clause, ns) {
|
|
263
576
|
const runtime = this;
|
|
264
577
|
const ownWorldId = () => runtime.host.entityId(entity.id);
|
|
265
|
-
const
|
|
266
|
-
const occurrenceKey = state_keys_1.CHORD_OCCURRENCE_PREFIX + `on.${entity.id}.${clause.action}`;
|
|
578
|
+
const occurrenceKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + `on.${ns}`;
|
|
267
579
|
return {
|
|
268
580
|
preValidate(target, world, _actorId, data) {
|
|
269
581
|
if (target.id !== ownWorldId() || clause.clauseKind === 'after')
|
|
270
582
|
return null;
|
|
583
|
+
const bag = runtime.clauseBag(data, ns);
|
|
271
584
|
const ctx = { world, it: entity.id };
|
|
272
585
|
// D8 (ADR-228): the `while` gate is evaluated once per firing, at
|
|
273
586
|
// validate time, BEFORE findRefusal — a gated-out clause sits out
|
|
@@ -275,13 +588,13 @@ class ChordRuntime {
|
|
|
275
588
|
// evaluate the gate: no mutation occurs between them within one
|
|
276
589
|
// action, so the answers cannot differ. Do not move this evaluation.
|
|
277
590
|
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
278
|
-
|
|
591
|
+
bag.skip = true;
|
|
279
592
|
return null;
|
|
280
593
|
}
|
|
281
594
|
// `, once`: a clause that has already fired keeps its refusal out
|
|
282
595
|
// too (peek only — the occurrence bump stays in postValidate).
|
|
283
596
|
if (clause.once && (world.getStateValue(occurrenceKey) ?? 0) >= 1) {
|
|
284
|
-
|
|
597
|
+
bag.skip = true;
|
|
285
598
|
return null;
|
|
286
599
|
}
|
|
287
600
|
const refusal = runtime.findRefusal(clause.body, ctx);
|
|
@@ -290,34 +603,36 @@ class ChordRuntime {
|
|
|
290
603
|
postValidate(target, world, _actorId, data) {
|
|
291
604
|
if (target.id !== ownWorldId())
|
|
292
605
|
return null;
|
|
606
|
+
const bag = runtime.clauseBag(data, ns);
|
|
293
607
|
const ctx = { world, it: entity.id };
|
|
294
608
|
// D8: same gate, same evaluation point (see preValidate).
|
|
295
609
|
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
296
|
-
|
|
610
|
+
bag.skip = true; // `while <cond>` gate — clause sits out this firing
|
|
297
611
|
return null;
|
|
298
612
|
}
|
|
299
|
-
const
|
|
300
|
-
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
613
|
+
const occurrence = (world.getStateValue(occurrenceKey) ?? 0) + 1;
|
|
301
614
|
if (clause.once && occurrence > 1) {
|
|
302
|
-
|
|
615
|
+
bag.skip = true; // `, once` — one lifetime firing (D5)
|
|
303
616
|
return null;
|
|
304
617
|
}
|
|
305
|
-
world.setStateValue(
|
|
618
|
+
world.setStateValue(occurrenceKey, occurrence);
|
|
306
619
|
ctx.occurrence = occurrence;
|
|
307
|
-
|
|
308
|
-
|
|
620
|
+
bag.occurrence = occurrence;
|
|
621
|
+
bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
|
|
309
622
|
return null;
|
|
310
623
|
},
|
|
311
624
|
postExecute(target, world, _actorId, data) {
|
|
312
|
-
|
|
625
|
+
const bag = runtime.clauseBag(data, ns);
|
|
626
|
+
if (target.id !== ownWorldId() || bag.skip === true)
|
|
313
627
|
return;
|
|
314
|
-
const ctx = runtime.restoreCtx(world, entity.id,
|
|
628
|
+
const ctx = runtime.restoreCtx(world, entity.id, bag);
|
|
315
629
|
runtime.execStatements(clause.body, ctx, 'mutations');
|
|
316
630
|
},
|
|
317
631
|
postReport(target, world, _actorId, data) {
|
|
318
|
-
|
|
632
|
+
const bag = runtime.clauseBag(data, ns);
|
|
633
|
+
if (target.id !== ownWorldId() || bag.skip === true)
|
|
319
634
|
return {};
|
|
320
|
-
const ctx = runtime.restoreCtx(world, entity.id,
|
|
635
|
+
const ctx = runtime.restoreCtx(world, entity.id, bag);
|
|
321
636
|
const reports = runtime.execStatements(clause.body, ctx, 'reports');
|
|
322
637
|
const result = {};
|
|
323
638
|
const emit = [];
|
|
@@ -339,12 +654,110 @@ class ChordRuntime {
|
|
|
339
654
|
},
|
|
340
655
|
};
|
|
341
656
|
}
|
|
342
|
-
restoreCtx(world, itIrId,
|
|
657
|
+
restoreCtx(world, itIrId, bag) {
|
|
343
658
|
return {
|
|
344
659
|
world,
|
|
345
660
|
it: itIrId,
|
|
346
|
-
occurrence:
|
|
347
|
-
decisions:
|
|
661
|
+
occurrence: bag.occurrence,
|
|
662
|
+
decisions: bag.decisions,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
// ------------------------------------------------- topic tables (ADR-239)
|
|
666
|
+
/**
|
|
667
|
+
* Runtime dispatch for one topic-table owner (ADR-239 D4/D5): normalized
|
|
668
|
+
* whole-topic lookup against the declared rows — entity tier first (the
|
|
669
|
+
* platform's quiet `topicEntityId` resolution), then free-text tier
|
|
670
|
+
* (primary or declared alias; the SAME normalizeTopic the analyzer's
|
|
671
|
+
* overlap gates used — one implementation, imported from chord). A hit
|
|
672
|
+
* runs the matched ROW's body exactly like a one-clause `on` firing
|
|
673
|
+
* (its first phrase OVERRIDES the primary message; the catch-all never
|
|
674
|
+
* runs — suppression, not append). A miss falls to the owner's
|
|
675
|
+
* catch-all clause when one is declared; with none, `{}` leaves the
|
|
676
|
+
* action's unconditional unknown_topic/not_interested default standing.
|
|
677
|
+
* The asked topic reaches `data` via the lifecycle seedData hook.
|
|
678
|
+
*/
|
|
679
|
+
buildTopicArm(entity, catchAll) {
|
|
680
|
+
const runtime = this;
|
|
681
|
+
const rows = entity.topics ?? [];
|
|
682
|
+
/** Match once per firing; memoized on the consultation's sharedData. */
|
|
683
|
+
const rowIndexFor = (data) => {
|
|
684
|
+
if (typeof data.chordTopicRow === 'number')
|
|
685
|
+
return data.chordTopicRow;
|
|
686
|
+
const askedEntity = typeof data.topicEntityId === 'string' ? data.topicEntityId : null;
|
|
687
|
+
const askedText = typeof data.topic === 'string' ? (0, chord_1.normalizeTopic)(data.topic) : null;
|
|
688
|
+
let index = -1;
|
|
689
|
+
if (askedEntity !== null) {
|
|
690
|
+
index = rows.findIndex((r) => r.filter.kind === 'entity' && runtime.host.entityId(r.filter.id) === askedEntity);
|
|
691
|
+
}
|
|
692
|
+
if (index === -1 && askedText !== null && askedText !== '') {
|
|
693
|
+
index = rows.findIndex((r) => r.filter.kind === 'text' &&
|
|
694
|
+
((0, chord_1.normalizeTopic)(r.filter.primary) === askedText || r.filter.aliases.some((a) => (0, chord_1.normalizeTopic)(a) === askedText)));
|
|
695
|
+
}
|
|
696
|
+
data.chordTopicRow = index;
|
|
697
|
+
return index;
|
|
698
|
+
};
|
|
699
|
+
// One occurrence namespace per ROW, shared across ask and tell (D1 —
|
|
700
|
+
// one table serves both): a row-body `first time` ordinal counts
|
|
701
|
+
// deliveries of that response, however it was reached.
|
|
702
|
+
const occurrenceKeyOf = (rowIndex) => `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}topic.${entity.id}.${rowIndex}`;
|
|
703
|
+
return {
|
|
704
|
+
preValidate(target, world, actorId, data) {
|
|
705
|
+
const row = rows[rowIndexFor(data)];
|
|
706
|
+
if (!row)
|
|
707
|
+
return catchAll?.preValidate?.(target, world, actorId, data) ?? null;
|
|
708
|
+
const ctx = { world, it: entity.id };
|
|
709
|
+
const refusal = runtime.findRefusal(row.body, ctx);
|
|
710
|
+
return refusal ? { valid: false, error: refusal } : null;
|
|
711
|
+
},
|
|
712
|
+
postValidate(target, world, actorId, data) {
|
|
713
|
+
const index = rowIndexFor(data);
|
|
714
|
+
const row = rows[index];
|
|
715
|
+
if (!row)
|
|
716
|
+
return catchAll?.postValidate?.(target, world, actorId, data) ?? null;
|
|
717
|
+
const bag = runtime.clauseBag(data, `topic.${entity.id}`);
|
|
718
|
+
const ctx = { world, it: entity.id };
|
|
719
|
+
const key = occurrenceKeyOf(index);
|
|
720
|
+
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
721
|
+
world.setStateValue(key, occurrence);
|
|
722
|
+
ctx.occurrence = occurrence;
|
|
723
|
+
bag.occurrence = occurrence;
|
|
724
|
+
bag.decisions = runtime.snapshotDecisions(row.body, ctx);
|
|
725
|
+
return null;
|
|
726
|
+
},
|
|
727
|
+
postExecute(target, world, actorId, data) {
|
|
728
|
+
const row = rows[rowIndexFor(data)];
|
|
729
|
+
if (!row) {
|
|
730
|
+
catchAll?.postExecute?.(target, world, actorId, data);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const ctx = runtime.restoreCtx(world, entity.id, runtime.clauseBag(data, `topic.${entity.id}`));
|
|
734
|
+
runtime.execStatements(row.body, ctx, 'mutations');
|
|
735
|
+
},
|
|
736
|
+
postReport(target, world, actorId, data) {
|
|
737
|
+
const row = rows[rowIndexFor(data)];
|
|
738
|
+
if (!row)
|
|
739
|
+
return catchAll?.postReport?.(target, world, actorId, data) ?? {};
|
|
740
|
+
const ctx = runtime.restoreCtx(world, entity.id, runtime.clauseBag(data, `topic.${entity.id}`));
|
|
741
|
+
const reports = runtime.execStatements(row.body, ctx, 'reports');
|
|
742
|
+
const result = {};
|
|
743
|
+
const emit = [];
|
|
744
|
+
for (const event of reports) {
|
|
745
|
+
const payload = (event.data ?? {});
|
|
746
|
+
if (event.type === 'chord.phrase' && !result.override) {
|
|
747
|
+
// A hit fully owns the response (D5) — override, never append.
|
|
748
|
+
result.override = {
|
|
749
|
+
messageId: String(payload.messageId),
|
|
750
|
+
params: payload.params ?? {},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
emit.push({ type: event.type, payload });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (emit.length)
|
|
758
|
+
result.emit = emit;
|
|
759
|
+
return result;
|
|
760
|
+
},
|
|
348
761
|
};
|
|
349
762
|
}
|
|
350
763
|
// ------------------------------------------- dispatch verbs (Phase B, §5.4)
|
|
@@ -352,7 +765,7 @@ class ChordRuntime {
|
|
|
352
765
|
writeChordTraitField(world, worldId, field, value, span) {
|
|
353
766
|
const entity = world.getEntity(worldId);
|
|
354
767
|
for (const trait of entity?.traits.values() ?? []) {
|
|
355
|
-
if (!trait.type.startsWith(
|
|
768
|
+
if (!trait.type.startsWith(state_keys_js_1.CHORD_TRAIT_PREFIX))
|
|
356
769
|
continue;
|
|
357
770
|
const record = trait;
|
|
358
771
|
if (field in record) {
|
|
@@ -360,7 +773,7 @@ class ChordRuntime {
|
|
|
360
773
|
return;
|
|
361
774
|
}
|
|
362
775
|
}
|
|
363
|
-
throw new
|
|
776
|
+
throw new errors_js_1.LoadError(`No trait on this entity carries the field \`${field}\`.`, span);
|
|
364
777
|
}
|
|
365
778
|
/**
|
|
366
779
|
* A `define trait` clause on a dispatch verb → CapabilityBehavior
|
|
@@ -390,7 +803,7 @@ class ChordRuntime {
|
|
|
390
803
|
data.chordSkip = true;
|
|
391
804
|
return { valid: true };
|
|
392
805
|
}
|
|
393
|
-
const key = `${
|
|
806
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${traitName}.${clause.action}.${runtime.host.irIdOf(entity.id)}`;
|
|
394
807
|
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
395
808
|
if (clause.once && occurrence > 1) {
|
|
396
809
|
data.chordSkip = true; // `, once` — one lifetime firing (D5)
|
|
@@ -427,11 +840,10 @@ class ChordRuntime {
|
|
|
427
840
|
* ActionInterceptor registered under the trait type (ADR-118 resolves it
|
|
428
841
|
* for every entity carrying the trait).
|
|
429
842
|
*/
|
|
430
|
-
buildTraitInterceptor(clause) {
|
|
843
|
+
buildTraitInterceptor(clause, ns) {
|
|
431
844
|
const runtime = this;
|
|
432
845
|
const itOf = (target) => runtime.host.irIdOf(target.id) ?? target.id;
|
|
433
|
-
const
|
|
434
|
-
const occurrenceKeyOf = (target) => `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${clause.action}.${itOf(target)}`;
|
|
846
|
+
const occurrenceKeyOf = (target) => `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${ns}.${itOf(target)}`;
|
|
435
847
|
return {
|
|
436
848
|
preValidate(target, world, _actorId, data) {
|
|
437
849
|
if (clause.clauseKind === 'after')
|
|
@@ -442,52 +854,58 @@ class ChordRuntime {
|
|
|
442
854
|
// entirely, refusals included. preValidate and postValidate may both
|
|
443
855
|
// evaluate the gate: no mutation occurs between them within one
|
|
444
856
|
// action, so the answers cannot differ. Do not move this evaluation.
|
|
857
|
+
const bag = runtime.clauseBag(data, ns);
|
|
445
858
|
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
446
|
-
|
|
859
|
+
bag.skip = true;
|
|
447
860
|
return null;
|
|
448
861
|
}
|
|
449
862
|
// `, once`: a clause that has already fired keeps its refusal out
|
|
450
863
|
// too (peek only — the occurrence bump stays in postValidate).
|
|
451
864
|
if (clause.once && (world.getStateValue(occurrenceKeyOf(target)) ?? 0) >= 1) {
|
|
452
|
-
|
|
865
|
+
bag.skip = true;
|
|
453
866
|
return null;
|
|
454
867
|
}
|
|
455
868
|
const refusal = runtime.findRefusal(clause.body, ctx);
|
|
456
869
|
return refusal ? { valid: false, error: refusal } : null;
|
|
457
870
|
},
|
|
458
871
|
postValidate(target, world, _actorId, data) {
|
|
872
|
+
const bag = runtime.clauseBag(data, ns);
|
|
459
873
|
const ctx = { world, it: itOf(target) };
|
|
460
874
|
// D8: same gate, same evaluation point (see preValidate).
|
|
461
875
|
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
462
|
-
|
|
876
|
+
bag.skip = true; // `while <cond>` gate — clause sits out this firing
|
|
463
877
|
return null;
|
|
464
878
|
}
|
|
465
879
|
const key = occurrenceKeyOf(target);
|
|
466
880
|
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
467
881
|
if (clause.once && occurrence > 1) {
|
|
468
|
-
|
|
882
|
+
bag.skip = true; // `, once` — one lifetime firing (D5)
|
|
469
883
|
return null;
|
|
470
884
|
}
|
|
471
885
|
world.setStateValue(key, occurrence);
|
|
472
886
|
ctx.occurrence = occurrence;
|
|
473
|
-
|
|
474
|
-
|
|
887
|
+
bag.occurrence = occurrence;
|
|
888
|
+
bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
|
|
475
889
|
return null;
|
|
476
890
|
},
|
|
477
891
|
postExecute(target, world, _actorId, data) {
|
|
478
|
-
|
|
892
|
+
const bag = runtime.clauseBag(data, ns);
|
|
893
|
+
if (bag.skip === true)
|
|
479
894
|
return;
|
|
480
|
-
runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target),
|
|
895
|
+
runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'mutations');
|
|
481
896
|
},
|
|
482
897
|
postReport(target, world, _actorId, data) {
|
|
483
|
-
|
|
898
|
+
const bag = runtime.clauseBag(data, ns);
|
|
899
|
+
if (bag.skip === true)
|
|
484
900
|
return {};
|
|
485
|
-
const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target),
|
|
901
|
+
const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'reports');
|
|
486
902
|
const result = {};
|
|
487
903
|
const emit = [];
|
|
488
904
|
for (const event of reports) {
|
|
489
905
|
const payload = (event.data ?? {});
|
|
490
|
-
|
|
906
|
+
// Only `on` clauses override the primary message; `after` phrases
|
|
907
|
+
// APPEND (ratchet D3 — mirrors the entity interceptor's guard).
|
|
908
|
+
if (clause.clauseKind === 'on' && event.type === 'chord.phrase' && !result.override) {
|
|
491
909
|
result.override = { messageId: String(payload.messageId), params: payload.params ?? {} };
|
|
492
910
|
}
|
|
493
911
|
else {
|
|
@@ -622,6 +1040,14 @@ class ChordRuntime {
|
|
|
622
1040
|
},
|
|
623
1041
|
blocked(context, result) {
|
|
624
1042
|
const key = result.error ?? def.otherwise ?? 'cant';
|
|
1043
|
+
// Platform default (Phase 8 #13): `'cant'` is the built-in fallback
|
|
1044
|
+
// key for an action with no authored `otherwise`/refusal — no story
|
|
1045
|
+
// phrase exists for it, and phraseEvent would throw a LoadError at
|
|
1046
|
+
// emit time. Render the platform's generic refusal instead
|
|
1047
|
+
// (lang-en-us `scope.out_of_scope`: "You can't do that.").
|
|
1048
|
+
if (key === 'cant') {
|
|
1049
|
+
return [context.event('action.blocked', { messageId: 'scope.out_of_scope', reason: 'cant' })];
|
|
1050
|
+
}
|
|
625
1051
|
const event = runtime.phraseEvent(key, { world: context.world });
|
|
626
1052
|
return [context.event(event.type, (event.data ?? {}))];
|
|
627
1053
|
},
|
|
@@ -661,7 +1087,7 @@ class ChordRuntime {
|
|
|
661
1087
|
// <state>` on a state anchor (ratchet D10). Pointer and last-fired
|
|
662
1088
|
// turn live in world state — save/restore covers progression.
|
|
663
1089
|
const slug = sequence.name.replace(/\s+/g, '-');
|
|
664
|
-
const key = `${
|
|
1090
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
|
|
665
1091
|
const firedKey = `${key}.turn`;
|
|
666
1092
|
const stepReady = (step, world, turn) => {
|
|
667
1093
|
switch (step.timing) {
|
|
@@ -675,9 +1101,9 @@ class ChordRuntime {
|
|
|
675
1101
|
if (!step.anchor)
|
|
676
1102
|
return false;
|
|
677
1103
|
if (step.anchor.owner === 'story') {
|
|
678
|
-
return world.getStateValue(
|
|
1104
|
+
return world.getStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY) === step.anchor.state;
|
|
679
1105
|
}
|
|
680
|
-
return world.getStateValue(
|
|
1106
|
+
return world.getStateValue(state_keys_js_1.CHORD_STATE_PREFIX + step.anchor.owner) === step.anchor.state;
|
|
681
1107
|
}
|
|
682
1108
|
}
|
|
683
1109
|
};
|
|
@@ -705,7 +1131,7 @@ class ChordRuntime {
|
|
|
705
1131
|
irEntity.onClauses.forEach((clause, clauseIndex) => {
|
|
706
1132
|
if (clause.binding !== 'every-turn')
|
|
707
1133
|
return;
|
|
708
|
-
const key = `${
|
|
1134
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}entity-turn.${irEntity.id}.${clauseIndex}`;
|
|
709
1135
|
daemons.push({
|
|
710
1136
|
id: `chord.entity-turn.${irEntity.id}.${clauseIndex}`,
|
|
711
1137
|
name: `on every turn (${irEntity.id})`,
|
|
@@ -730,6 +1156,31 @@ class ChordRuntime {
|
|
|
730
1156
|
});
|
|
731
1157
|
});
|
|
732
1158
|
});
|
|
1159
|
+
// Story-owned every-turn clauses (`on every turn` in the story header
|
|
1160
|
+
// body — ADR-236 D7, ratchet R4): one daemon per clause with NO
|
|
1161
|
+
// presence gate — the story is everywhere ("a background clock for the
|
|
1162
|
+
// whole game"); narration broadcasts. `it` never appears in the body
|
|
1163
|
+
// (the analyzer's story-clause-it gate refused it at compile).
|
|
1164
|
+
(this.ir.story.onClauses ?? []).forEach((clause, clauseIndex) => {
|
|
1165
|
+
if (clause.binding !== 'every-turn')
|
|
1166
|
+
return;
|
|
1167
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}story-turn.${clauseIndex}`;
|
|
1168
|
+
daemons.push({
|
|
1169
|
+
id: `chord.story-turn.${clauseIndex}`,
|
|
1170
|
+
name: 'on every turn (story)',
|
|
1171
|
+
run: (ctx) => {
|
|
1172
|
+
const evalCtx = { world: ctx.world };
|
|
1173
|
+
if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
|
|
1174
|
+
return [];
|
|
1175
|
+
const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
|
|
1176
|
+
if (clause.once && fired > 1)
|
|
1177
|
+
return []; // `, once` (D5)
|
|
1178
|
+
ctx.world.setStateValue(key, fired);
|
|
1179
|
+
evalCtx.occurrence = fired;
|
|
1180
|
+
return this.narrated(this.execStatements(clause.body, evalCtx));
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
733
1184
|
// Every-turn trait clauses (`on every turn while …[, once]`): one
|
|
734
1185
|
// daemon per clause, evaluated per entity carrying the trait. The
|
|
735
1186
|
// composition condition (`chatty while not after-hours`) gates per
|
|
@@ -738,7 +1189,7 @@ class ChordRuntime {
|
|
|
738
1189
|
trait.onClauses.forEach((clause, clauseIndex) => {
|
|
739
1190
|
if (clause.binding !== 'every-turn')
|
|
740
1191
|
return;
|
|
741
|
-
const traitType =
|
|
1192
|
+
const traitType = state_keys_js_1.CHORD_TRAIT_PREFIX + trait.name;
|
|
742
1193
|
daemons.push({
|
|
743
1194
|
id: `chord.trait-turn.${trait.name}.${clauseIndex}`,
|
|
744
1195
|
name: `on every turn (${trait.name})`,
|
|
@@ -761,7 +1212,7 @@ class ChordRuntime {
|
|
|
761
1212
|
continue;
|
|
762
1213
|
if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
|
|
763
1214
|
continue;
|
|
764
|
-
const key = `${
|
|
1215
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait-turn.${trait.name}.${clauseIndex}.${irEntity.id}`;
|
|
765
1216
|
const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
|
|
766
1217
|
if (clause.once && fired > 1)
|
|
767
1218
|
continue; // `, once` (D5)
|
|
@@ -794,11 +1245,23 @@ class ChordRuntime {
|
|
|
794
1245
|
narrated(events) {
|
|
795
1246
|
return events.map((e) => ({ ...e, narrate: true }));
|
|
796
1247
|
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Execute a machine body (`on enter`/`on exit`/transition effects,
|
|
1250
|
+
* ADR-215 state-machines depth) — story-owned: no `it` (compile-gated),
|
|
1251
|
+
* narration broadcasts like any story-owned surface.
|
|
1252
|
+
* @param statements the resolved IR statement tree
|
|
1253
|
+
* @param world the live world the effect runs against
|
|
1254
|
+
*/
|
|
1255
|
+
execMachineBody(statements, world) {
|
|
1256
|
+
return this.narrated(this.execStatements(statements, { world }));
|
|
1257
|
+
}
|
|
797
1258
|
/**
|
|
798
1259
|
* Decision 10 presence semantics: the player shares the owner's location.
|
|
799
|
-
* A room owner means the player is IN that room;
|
|
800
|
-
*
|
|
801
|
-
*
|
|
1260
|
+
* A room owner means the player is IN that room; a region owner "is" at
|
|
1261
|
+
* every member room — presence is `isInRegion(player, region)`, transitive
|
|
1262
|
+
* through nesting (ADR-236 D4); for anything else the two share a
|
|
1263
|
+
* containing room (same co-location rule as can-see/can-reach — presence,
|
|
1264
|
+
* not sight, so the snake speaks in darkness).
|
|
802
1265
|
*/
|
|
803
1266
|
playerPresentAt(world, irEntityId) {
|
|
804
1267
|
const ownerId = this.host.entityId(irEntityId);
|
|
@@ -807,8 +1270,10 @@ class ChordRuntime {
|
|
|
807
1270
|
return false;
|
|
808
1271
|
if (ownerId === playerId)
|
|
809
1272
|
return true;
|
|
810
|
-
const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
|
|
811
1273
|
const owner = world.getEntity(ownerId);
|
|
1274
|
+
if (owner?.has(world_model_1.TraitType.REGION))
|
|
1275
|
+
return world.isInRegion(playerId, ownerId);
|
|
1276
|
+
const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
|
|
812
1277
|
if (owner?.has(world_model_1.TraitType.ROOM))
|
|
813
1278
|
return playerRoom === ownerId;
|
|
814
1279
|
const ownerRoom = world.getContainingRoom(ownerId)?.id ?? world.getLocation(ownerId);
|
|
@@ -834,7 +1299,7 @@ class ChordRuntime {
|
|
|
834
1299
|
const ctx = { world, it: targetIrId };
|
|
835
1300
|
if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
|
|
836
1301
|
return;
|
|
837
|
-
const key = `${
|
|
1302
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}after.${irEntity.id}.${actionName}.${clauseIndex}`;
|
|
838
1303
|
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
839
1304
|
if (clause.once && occurrence > 1)
|
|
840
1305
|
return; // `, once` (D5)
|
|
@@ -856,49 +1321,53 @@ class ChordRuntime {
|
|
|
856
1321
|
}
|
|
857
1322
|
return out;
|
|
858
1323
|
}
|
|
1324
|
+
// ADR-240 D4: `recomputeDerived` and its trigger wiring are DELETED — the
|
|
1325
|
+
// registered evaluators above are consulted live at every read; there is
|
|
1326
|
+
// no cached derivation left to refresh.
|
|
859
1327
|
/**
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
1328
|
+
* Blocked-exit refusal text, resolved AT REFUSAL TIME (ADR-240 D6): a
|
|
1329
|
+
* multi-variant phrase honors its strategy per attempt — `randomly`
|
|
1330
|
+
* through the seeded story RNG, `cycling`/`stopping`/`first-time`
|
|
1331
|
+
* through a world-state counter, `sticky` through a stored pick — so
|
|
1332
|
+
* refusal text varies exactly as ADR-211 phrase semantics intend.
|
|
863
1333
|
*/
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1334
|
+
blockedPhraseText(key, world) {
|
|
1335
|
+
const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
|
|
1336
|
+
if (!phrase)
|
|
1337
|
+
return '';
|
|
1338
|
+
const variants = phrase.variants;
|
|
1339
|
+
if (variants.length <= 1)
|
|
1340
|
+
return variants[0]?.text ?? '';
|
|
1341
|
+
const stateKey = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}blocked.${key}`;
|
|
1342
|
+
switch (phrase.strategy) {
|
|
1343
|
+
case 'randomly':
|
|
1344
|
+
return variants[this.evaluator.pickIndex(variants.length, world)].text;
|
|
1345
|
+
case 'sticky': {
|
|
1346
|
+
const stored = world.getStateValue(stateKey);
|
|
1347
|
+
if (typeof stored === 'number')
|
|
1348
|
+
return variants[stored].text;
|
|
1349
|
+
const pick = this.evaluator.pickIndex(variants.length, world);
|
|
1350
|
+
world.setStateValue(stateKey, pick);
|
|
1351
|
+
return variants[pick].text;
|
|
1352
|
+
}
|
|
1353
|
+
case 'stopping': {
|
|
1354
|
+
const n = world.getStateValue(stateKey) ?? 0;
|
|
1355
|
+
world.setStateValue(stateKey, Math.min(n + 1, variants.length - 1));
|
|
1356
|
+
return variants[Math.min(n, variants.length - 1)].text;
|
|
1357
|
+
}
|
|
1358
|
+
case 'first-time': {
|
|
1359
|
+
const n = world.getStateValue(stateKey) ?? 0;
|
|
1360
|
+
world.setStateValue(stateKey, n + 1);
|
|
1361
|
+
return variants[n === 0 ? 0 : Math.min(1, variants.length - 1)].text;
|
|
1362
|
+
}
|
|
1363
|
+
case 'cycling':
|
|
1364
|
+
default: {
|
|
1365
|
+
const n = world.getStateValue(stateKey) ?? 0;
|
|
1366
|
+
world.setStateValue(stateKey, n + 1);
|
|
1367
|
+
return variants[n % variants.length].text;
|
|
890
1368
|
}
|
|
891
1369
|
}
|
|
892
1370
|
}
|
|
893
|
-
/** Single-variant text of a blocked-exit phrase (mirrors the loader's read). */
|
|
894
|
-
blockedPhraseText(key) {
|
|
895
|
-
const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
|
|
896
|
-
return phrase?.variants[0]?.text ?? '';
|
|
897
|
-
}
|
|
898
|
-
/** True when any entity declares a conditional blocked exit. */
|
|
899
|
-
hasConditionalBlockedExits() {
|
|
900
|
-
return this.ir.entities.some((e) => e.blockedExits.some((b) => b.condition !== null));
|
|
901
|
-
}
|
|
902
1371
|
// ------------------------------------------------------------ statements
|
|
903
1372
|
/**
|
|
904
1373
|
* Execute a statement tree. `phase` narrows which leaves act:
|
|
@@ -919,8 +1388,15 @@ class ChordRuntime {
|
|
|
919
1388
|
events.push(this.phraseEvent(stmt.phraseKey, ctx, stmt.params));
|
|
920
1389
|
break;
|
|
921
1390
|
case 'emit':
|
|
1391
|
+
// ADR-216: the payload evaluates live against the turn context —
|
|
1392
|
+
// literals as numbers/strings, value expressions through the
|
|
1393
|
+
// shared evaluator, arrays/objects recursively.
|
|
1394
|
+
// ADR-256: the Chord IR event id is dotless; translate it to the
|
|
1395
|
+
// platform runtime type here (media.* → dotted; author events pass
|
|
1396
|
+
// through). Not inside rawEvent — that also mints the internal
|
|
1397
|
+
// `chord.phrase` event, which must not be translated.
|
|
922
1398
|
if (phase !== 'mutations' && whenHolds(stmt))
|
|
923
|
-
events.push(this.rawEvent(stmt.event,
|
|
1399
|
+
events.push(this.rawEvent((0, event_id_map_js_1.translateEventId)(stmt.event), this.emitPayload(stmt.payload, ctx)));
|
|
924
1400
|
break;
|
|
925
1401
|
case 'win':
|
|
926
1402
|
case 'lose':
|
|
@@ -954,16 +1430,16 @@ class ChordRuntime {
|
|
|
954
1430
|
if (phase !== 'reports' && whenHolds(stmt)) {
|
|
955
1431
|
if (stmt.entity.kind === 'story') {
|
|
956
1432
|
// `change the story to <state>` — the story object's phase (D2).
|
|
957
|
-
this.checkForwardMarch(this.ir.story.states, this.ir.story.reversible, ctx.world.getStateValue(
|
|
958
|
-
ctx.world.setStateValue(
|
|
1433
|
+
this.checkForwardMarch(this.ir.story.states, this.ir.story.reversible, ctx.world.getStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY), stmt.state, 'the story', stmt.span);
|
|
1434
|
+
ctx.world.setStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY, stmt.state);
|
|
959
1435
|
}
|
|
960
1436
|
else {
|
|
961
1437
|
const irId = this.irIdOfValue(stmt.entity, ctx);
|
|
962
1438
|
const set = this.stateSetOf(irId, stmt.state);
|
|
963
1439
|
if (set) {
|
|
964
|
-
this.checkForwardMarch(set.states, set.reversible, ctx.world.getStateValue(
|
|
1440
|
+
this.checkForwardMarch(set.states, set.reversible, ctx.world.getStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irId), stmt.state, irId, stmt.span);
|
|
965
1441
|
}
|
|
966
|
-
ctx.world.setStateValue(
|
|
1442
|
+
ctx.world.setStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irId, stmt.state);
|
|
967
1443
|
}
|
|
968
1444
|
}
|
|
969
1445
|
break;
|
|
@@ -997,7 +1473,7 @@ class ChordRuntime {
|
|
|
997
1473
|
this.writeChordTraitField(ctx.world, baseId, stmt.target.field, value, stmt.span);
|
|
998
1474
|
}
|
|
999
1475
|
else {
|
|
1000
|
-
throw new
|
|
1476
|
+
throw new errors_js_1.LoadError('`set` targets a trait data field.', stmt.span);
|
|
1001
1477
|
}
|
|
1002
1478
|
}
|
|
1003
1479
|
break;
|
|
@@ -1008,12 +1484,12 @@ class ChordRuntime {
|
|
|
1008
1484
|
// awards are no-ops and `, once` is automatic. Names arrive
|
|
1009
1485
|
// owner-qualified from the analyzer (ratchet D12).
|
|
1010
1486
|
if (stmt.expression.length !== 1) {
|
|
1011
|
-
throw new
|
|
1487
|
+
throw new errors_js_1.LoadError('Only `award <score-name>` is supported (expression awards are later scope).', stmt.span);
|
|
1012
1488
|
}
|
|
1013
1489
|
const name = stmt.expression[0];
|
|
1014
1490
|
const worth = this.scoreWorth.get(name);
|
|
1015
1491
|
if (worth === undefined) {
|
|
1016
|
-
throw new
|
|
1492
|
+
throw new errors_js_1.LoadError(`\`${name}\` is not a declared score.`, stmt.span);
|
|
1017
1493
|
}
|
|
1018
1494
|
ctx.world.awardScore(name, worth, name);
|
|
1019
1495
|
}
|
|
@@ -1139,7 +1615,7 @@ class ChordRuntime {
|
|
|
1139
1615
|
const from = states.indexOf(current);
|
|
1140
1616
|
const to = states.indexOf(target);
|
|
1141
1617
|
if (from >= 0 && to >= 0 && to < from) {
|
|
1142
|
-
throw new
|
|
1618
|
+
throw new errors_js_1.LoadError(`\`change\` to \`${target}\` moves ${ownerDesc} backward in a forward-only set (currently \`${current}\`) — add \`, reversible\` to the \`states:\` line to permit back-transitions (D4).`, span);
|
|
1143
1619
|
}
|
|
1144
1620
|
}
|
|
1145
1621
|
/**
|
|
@@ -1151,21 +1627,33 @@ class ChordRuntime {
|
|
|
1151
1627
|
findRefusal(body, ctx) {
|
|
1152
1628
|
for (const stmt of body) {
|
|
1153
1629
|
if (stmt.kind === 'refuse')
|
|
1154
|
-
return stmt.phraseKey;
|
|
1630
|
+
return this.resolvePhraseKey(stmt.phraseKey, ctx);
|
|
1155
1631
|
if (stmt.kind === 'must') {
|
|
1156
1632
|
if (!this.evaluator.evalCondition(stmt.condition, ctx))
|
|
1157
|
-
return stmt.phraseKey;
|
|
1633
|
+
return this.resolvePhraseKey(stmt.phraseKey, ctx);
|
|
1158
1634
|
continue;
|
|
1159
1635
|
}
|
|
1160
1636
|
if (stmt.kind === 'refuse-when') {
|
|
1161
1637
|
if (this.evaluator.evalCondition(stmt.condition, ctx))
|
|
1162
|
-
return stmt.phraseKey;
|
|
1638
|
+
return this.resolvePhraseKey(stmt.phraseKey, ctx);
|
|
1163
1639
|
continue;
|
|
1164
1640
|
}
|
|
1165
1641
|
break; // first non-refusal statement ends the validate partition
|
|
1166
1642
|
}
|
|
1167
1643
|
return null;
|
|
1168
1644
|
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Resolve a refusal phrase key to its registered message id. A
|
|
1647
|
+
* per-entity `phrase <key>:` declaration registers entity-scoped as
|
|
1648
|
+
* `<irId>.<key>` — the same override rule `phraseEvent` applies at emit
|
|
1649
|
+
* time — so a bare refusal key written inside that entity's clause must
|
|
1650
|
+
* travel as the scoped id: the key crosses into stdlib's blocked() as a
|
|
1651
|
+
* fully-qualified message id (ADR-231 D1) and is rendered verbatim.
|
|
1652
|
+
*/
|
|
1653
|
+
resolvePhraseKey(key, ctx) {
|
|
1654
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
1655
|
+
return ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
|
|
1656
|
+
}
|
|
1169
1657
|
/**
|
|
1170
1658
|
* Snapshot every branching decision (`if` and `select-on`) before
|
|
1171
1659
|
* mutations run, walking only the branches actually taken (§5.4: the
|
|
@@ -1232,7 +1720,7 @@ class ChordRuntime {
|
|
|
1232
1720
|
// the persisted chance stream (via one draw per firing). Sticky (Z5)
|
|
1233
1721
|
// reuses the same slot with the Choice encoding instead of an
|
|
1234
1722
|
// occurrence count: stored = chosen index + 1, 0/undefined = unchosen.
|
|
1235
|
-
const key =
|
|
1723
|
+
const key = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + `select.${stmt.span.line}`;
|
|
1236
1724
|
if (stmt.strategy === 'sticky') {
|
|
1237
1725
|
const stored = ctx.world.getStateValue(key);
|
|
1238
1726
|
if (stored && stored > 0)
|
|
@@ -1253,7 +1741,7 @@ class ChordRuntime {
|
|
|
1253
1741
|
case 'randomly':
|
|
1254
1742
|
return this.randomIndex(count, ctx);
|
|
1255
1743
|
default:
|
|
1256
|
-
throw new
|
|
1744
|
+
throw new errors_js_1.LoadError(`Unknown select strategy \`${stmt.strategy}\`.`, stmt.span);
|
|
1257
1745
|
}
|
|
1258
1746
|
}
|
|
1259
1747
|
randomIndex(count, ctx) {
|
|
@@ -1310,8 +1798,14 @@ class ChordRuntime {
|
|
|
1310
1798
|
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
1311
1799
|
const overrideKey = ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
|
|
1312
1800
|
const phrase = table[overrideKey];
|
|
1313
|
-
|
|
1314
|
-
|
|
1801
|
+
// ADR-250: a key covered only by phrasebooks has no table entry — emit
|
|
1802
|
+
// the bare key (the render-path book layer supplies the winning
|
|
1803
|
+
// template and its Choice) but still stage stmt params and any hatch
|
|
1804
|
+
// producers the book entries reference, since staging is emit-time work.
|
|
1805
|
+
const bookVariants = phrase ? null : this.bookEntriesFor(key).flatMap((e) => e.variants);
|
|
1806
|
+
if (!phrase && bookVariants.length === 0) {
|
|
1807
|
+
throw new errors_js_1.LoadError(`Phrase \`${key}\` is missing from the IR at emit time.`);
|
|
1808
|
+
}
|
|
1315
1809
|
const params = {};
|
|
1316
1810
|
// Authored `with <param> = <value>` bindings (zoo-chain follow-up,
|
|
1317
1811
|
// 2026-07-12): entity values pass as their display name (the template's
|
|
@@ -1321,7 +1815,7 @@ class ChordRuntime {
|
|
|
1321
1815
|
const asEntity = typeof value === 'string' ? ctx.world.getEntity(value) : undefined;
|
|
1322
1816
|
params[p.param] = asEntity ? asEntity.name : value;
|
|
1323
1817
|
}
|
|
1324
|
-
for (const variant of phrase.variants) {
|
|
1818
|
+
for (const variant of phrase ? phrase.variants : bookVariants) {
|
|
1325
1819
|
for (const marker of variant.markers) {
|
|
1326
1820
|
const producer = this.host.producers.get(marker);
|
|
1327
1821
|
if (producer) {
|
|
@@ -1332,10 +1826,10 @@ class ChordRuntime {
|
|
|
1332
1826
|
// producer reaching outside it fails HERE, named, not as an
|
|
1333
1827
|
// anonymous TypeError downstream.
|
|
1334
1828
|
try {
|
|
1335
|
-
params[marker] = producer((0,
|
|
1829
|
+
params[marker] = producer((0, hatch_context_js_1.stagingRenderContext)(ctx.world));
|
|
1336
1830
|
}
|
|
1337
1831
|
catch (error) {
|
|
1338
|
-
throw new
|
|
1832
|
+
throw new errors_js_1.LoadError(`Hatch \`${marker}\` threw while staging phrase \`${overrideKey}\`: ${error instanceof Error ? error.message : String(error)}. Hatches see the narrow staging context only (design.md §5.6).`);
|
|
1339
1833
|
}
|
|
1340
1834
|
}
|
|
1341
1835
|
}
|
|
@@ -1353,16 +1847,16 @@ class ChordRuntime {
|
|
|
1353
1847
|
params[name] = slotEntity.name;
|
|
1354
1848
|
}
|
|
1355
1849
|
}
|
|
1356
|
-
if (phrase
|
|
1850
|
+
if (phrase?.verbatim) {
|
|
1357
1851
|
// `{verbatim:text}` template (loader registration) — the atom is
|
|
1358
1852
|
// exempt from whitespace collapse, so line structure and interior
|
|
1359
1853
|
// spacing survive as authored (grammar log 2026-07-10).
|
|
1360
1854
|
params.text = phrase.variants[0]?.text ?? '';
|
|
1361
1855
|
}
|
|
1362
|
-
else if (phrase
|
|
1856
|
+
else if (phrase?.strategy) {
|
|
1363
1857
|
const choice = {
|
|
1364
1858
|
kind: 'choice',
|
|
1365
|
-
alternatives: phrase.variants.map((v) => ({ kind: 'literal', text: (0,
|
|
1859
|
+
alternatives: phrase.variants.map((v) => ({ kind: 'literal', text: (0, text_js_1.withLineBreaks)(v.text) })),
|
|
1366
1860
|
selector: exports.STRATEGY_SELECTOR[phrase.strategy],
|
|
1367
1861
|
entityId: counter?.entityId ?? 'chord',
|
|
1368
1862
|
messageKey: counter?.messageKey ?? overrideKey,
|
|
@@ -1371,6 +1865,38 @@ class ChordRuntime {
|
|
|
1371
1865
|
}
|
|
1372
1866
|
return this.rawEvent('chord.phrase', { messageId: overrideKey, params });
|
|
1373
1867
|
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Evaluate an emit payload (ADR-216) against the live turn context.
|
|
1870
|
+
* Keys pass VERBATIM; number literals become numbers; `true`/`false`
|
|
1871
|
+
* symbols become booleans; other value expressions evaluate through the
|
|
1872
|
+
* shared evaluator (entity refs → world ids, field reads → live values).
|
|
1873
|
+
*/
|
|
1874
|
+
emitPayload(fields, ctx) {
|
|
1875
|
+
const data = {};
|
|
1876
|
+
for (const field of fields ?? []) {
|
|
1877
|
+
data[field.key] = this.emitValue(field.value, ctx);
|
|
1878
|
+
}
|
|
1879
|
+
return data;
|
|
1880
|
+
}
|
|
1881
|
+
emitValue(value, ctx) {
|
|
1882
|
+
switch (value.kind) {
|
|
1883
|
+
case 'literal':
|
|
1884
|
+
return value.valueType === 'number' ? Number(value.value) : value.value;
|
|
1885
|
+
case 'value':
|
|
1886
|
+
if (value.value.kind === 'symbol' && (value.value.name === 'true' || value.value.name === 'false')) {
|
|
1887
|
+
return value.value.name === 'true';
|
|
1888
|
+
}
|
|
1889
|
+
return this.evaluator.evalValue(value.value, ctx);
|
|
1890
|
+
case 'array':
|
|
1891
|
+
return value.items.map((item) => this.emitValue(item, ctx));
|
|
1892
|
+
case 'object': {
|
|
1893
|
+
const nested = {};
|
|
1894
|
+
for (const field of value.fields)
|
|
1895
|
+
nested[field.key] = this.emitValue(field.value, ctx);
|
|
1896
|
+
return nested;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1374
1900
|
rawEvent(type, data) {
|
|
1375
1901
|
return {
|
|
1376
1902
|
id: `chord-${type}-${this.eventSeq++}`,
|
|
@@ -1386,13 +1912,13 @@ class ChordRuntime {
|
|
|
1386
1912
|
return value.id;
|
|
1387
1913
|
if (value.kind === 'it') {
|
|
1388
1914
|
if (!ctx.it)
|
|
1389
|
-
throw new
|
|
1915
|
+
throw new errors_js_1.LoadError('`it` used outside an entity-scoped clause.');
|
|
1390
1916
|
return ctx.it;
|
|
1391
1917
|
}
|
|
1392
1918
|
const worldId = this.evaluator.entityValue(value, ctx);
|
|
1393
1919
|
const irId = this.host.irIdOf(worldId);
|
|
1394
1920
|
if (!irId)
|
|
1395
|
-
throw new
|
|
1921
|
+
throw new errors_js_1.LoadError('Cannot change the state of a non-story entity.');
|
|
1396
1922
|
return irId;
|
|
1397
1923
|
}
|
|
1398
1924
|
}
|