@sharpee/story-loader 3.0.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/chain-map.d.ts +40 -0
- package/chain-map.d.ts.map +1 -0
- package/chain-map.js +39 -0
- package/chain-map.js.map +1 -0
- package/evaluator.d.ts +16 -0
- package/evaluator.d.ts.map +1 -1
- package/evaluator.js +59 -23
- package/evaluator.js.map +1 -1
- package/event-contract.d.ts +18 -0
- package/event-contract.d.ts.map +1 -1
- package/event-contract.js +28 -1
- package/event-contract.js.map +1 -1
- package/event-id-map.d.ts +42 -0
- package/event-id-map.d.ts.map +1 -0
- package/event-id-map.js +62 -0
- package/event-id-map.js.map +1 -0
- package/extension-registry.d.ts +54 -0
- package/extension-registry.d.ts.map +1 -0
- package/extension-registry.js +70 -0
- package/extension-registry.js.map +1 -0
- package/index.d.ts +9 -7
- package/index.d.ts.map +1 -1
- package/index.js +28 -25
- package/index.js.map +1 -1
- package/loader.d.ts +132 -5
- package/loader.d.ts.map +1 -1
- package/loader.js +1008 -109
- package/loader.js.map +1 -1
- package/message-alias-map.d.ts +25 -0
- package/message-alias-map.d.ts.map +1 -0
- package/message-alias-map.js +836 -0
- package/message-alias-map.js.map +1 -0
- package/package.json +11 -9
- package/phrasebook-data.d.ts +27 -0
- package/phrasebook-data.d.ts.map +1 -0
- package/phrasebook-data.js +6 -0
- package/phrasebook-data.js.map +1 -0
- package/runtime.d.ts +158 -19
- package/runtime.d.ts.map +1 -1
- package/runtime.js +846 -169
- package/runtime.js.map +1 -1
package/runtime.js
CHANGED
|
@@ -1,12 +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
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const stdlib_1 = require("@sharpee/stdlib");
|
|
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");
|
|
10
16
|
/**
|
|
11
17
|
* Chord strategy adverb → phrase-algebra Choice selector (ADR-196).
|
|
12
18
|
* The Z5 table (ADR-211 Decision 4): adverbs mirror the selectors 1:1;
|
|
@@ -27,6 +33,11 @@ class ChordBehaviorTrait {
|
|
|
27
33
|
type = ChordBehaviorTrait.type;
|
|
28
34
|
}
|
|
29
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'];
|
|
30
41
|
class ChordRuntime {
|
|
31
42
|
ir;
|
|
32
43
|
host;
|
|
@@ -57,80 +68,334 @@ class ChordRuntime {
|
|
|
57
68
|
return;
|
|
58
69
|
// Event clauses (`after entering it`) bind to the event stream per
|
|
59
70
|
// the selector contract — the ownership package's replacement for
|
|
60
|
-
// floating `when` rules.
|
|
61
|
-
|
|
62
|
-
|
|
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);
|
|
63
77
|
return;
|
|
64
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
|
+
}
|
|
84
|
+
// D5 fail-fast (ADR-228): only bind clauses something will consult.
|
|
85
|
+
if (!this.isConsultedGerund(clause.action)) {
|
|
86
|
+
if (this.isDispatchAction(clause.action)) {
|
|
87
|
+
// Dispatch reactions fire via fireAfterClauses (the runtime owns
|
|
88
|
+
// those actions — interceptors never fire on the dispatch path),
|
|
89
|
+
// so `after` is live without any registration here…
|
|
90
|
+
if (clause.clauseKind === 'after')
|
|
91
|
+
return;
|
|
92
|
+
// …but an entity `on` clause has no dispatch surface at all.
|
|
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);
|
|
94
|
+
}
|
|
95
|
+
throw this.deadGerundError(clause);
|
|
96
|
+
}
|
|
65
97
|
this.prepareOnClauseTarget(world, entity, clause);
|
|
66
98
|
const list = byAction.get(clause.action) ?? [];
|
|
67
99
|
list.push({ entity, clause });
|
|
68
100
|
byAction.set(clause.action, list);
|
|
69
101
|
});
|
|
70
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
|
+
}
|
|
71
119
|
for (const [action, clauses] of byAction) {
|
|
72
|
-
world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(clauses));
|
|
120
|
+
world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(action, clauses));
|
|
73
121
|
}
|
|
74
122
|
// Phase B: `define trait` clauses register per TRAIT TYPE — capability
|
|
75
123
|
// behaviors for dispatch verbs, interceptors for standard-semantics
|
|
76
124
|
// actions (§5.4 routing recorded on the IR by the analyzer).
|
|
77
125
|
for (const trait of this.ir.traits) {
|
|
78
|
-
const traitType =
|
|
126
|
+
const traitType = state_keys_js_1.CHORD_TRAIT_PREFIX + trait.name;
|
|
127
|
+
const interceptorClauses = new Map();
|
|
128
|
+
const capabilityActions = new Set();
|
|
79
129
|
for (const clause of trait.onClauses) {
|
|
80
130
|
if (clause.binding === 'every-turn')
|
|
81
131
|
continue; // scheduler phase (plan phase 5)
|
|
82
132
|
if (clause.binding === 'role') {
|
|
83
|
-
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);
|
|
84
134
|
}
|
|
85
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);
|
|
86
144
|
world.registerCapabilityBehavior(traitType, `chord.action.${clause.action}`, this.buildCapabilityBehavior(trait.name, clause));
|
|
87
145
|
}
|
|
88
146
|
else {
|
|
89
|
-
|
|
147
|
+
// D5 fail-fast (ADR-228): the analyzer routed this clause to the
|
|
148
|
+
// interceptor path, so its gerund must name a consulted action.
|
|
149
|
+
if (!this.isConsultedGerund(clause.action))
|
|
150
|
+
throw this.deadGerundError(clause);
|
|
151
|
+
const list = interceptorClauses.get(clause.action) ?? [];
|
|
152
|
+
list.push(clause);
|
|
153
|
+
interceptorClauses.set(clause.action, list);
|
|
90
154
|
}
|
|
91
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
|
+
}
|
|
92
162
|
}
|
|
93
|
-
// Derived properties (`dark while`,
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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));
|
|
113
342
|
}
|
|
114
343
|
}
|
|
115
344
|
}
|
|
345
|
+
// ------------------------------------------------------- D5 fail-fast
|
|
346
|
+
/**
|
|
347
|
+
* True when an interceptor registered under `if.action.<gerund>` can ever
|
|
348
|
+
* fire: a wired stdlib action consults the id (the ADR-228 D5 registry,
|
|
349
|
+
* derived from the descriptor table), or the gerund names a `define
|
|
350
|
+
* action X from` hatch — an author-owned TS Action the loader can't see
|
|
351
|
+
* inside, which may consult its own id.
|
|
352
|
+
* @param gerund the clause's action word (e.g. `taking`)
|
|
353
|
+
*/
|
|
354
|
+
isConsultedGerund(gerund) {
|
|
355
|
+
if (stdlib_1.interceptorConsultingActionIds.has(`if.action.${gerund}`))
|
|
356
|
+
return true;
|
|
357
|
+
return this.ir.hatches.some((h) => h.hatchKind === 'action' && h.name === gerund);
|
|
358
|
+
}
|
|
359
|
+
/** True when the gerund names a `define action` dispatch action. */
|
|
360
|
+
isDispatchAction(gerund) {
|
|
361
|
+
return this.ir.actions.some((a) => a.name === gerund);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Load-time diagnostic for a clause whose gerund nothing will ever
|
|
365
|
+
* consult (ADR-228 D5): a typo or an unimplemented action word would
|
|
366
|
+
* otherwise register and silently die. lowering/raising get the pointed
|
|
367
|
+
* capability-dispatch message (they are full-delegation by design).
|
|
368
|
+
* @param clause the dead clause (its span anchors the diagnostic)
|
|
369
|
+
*/
|
|
370
|
+
deadGerundError(clause) {
|
|
371
|
+
const phrase = `${clause.clauseKind} ${clause.action} it`;
|
|
372
|
+
if (clause.action === 'lowering' || clause.action === 'raising') {
|
|
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);
|
|
374
|
+
}
|
|
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);
|
|
376
|
+
}
|
|
116
377
|
// ---------------------------------------------------------- event clauses
|
|
117
378
|
/**
|
|
118
|
-
* Bind an event clause (`after entering it` on a room) to its
|
|
119
|
-
* event per the selector contract — the ownership package's
|
|
120
|
-
* for floating `when` rules: the same firing semantics,
|
|
121
|
-
* 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.
|
|
122
383
|
*/
|
|
123
|
-
bindEventClause(world, entity, clause, clauseIndex) {
|
|
124
|
-
const trigger = event_contract_1.EVENT_TRIGGERS[clause.action];
|
|
384
|
+
bindEventClause(world, entity, clause, clauseIndex, trigger) {
|
|
125
385
|
const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
|
|
126
386
|
world.chainEvent(trigger, (event, w) => this.fireEventClause(entity, clause, key, event, w), { key });
|
|
127
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
|
+
}
|
|
128
393
|
/** Test/debug entry: run every event clause bound to this event type. */
|
|
129
394
|
fireEventClauses(world, event) {
|
|
130
395
|
const out = [];
|
|
131
396
|
for (const entity of this.ir.entities) {
|
|
132
397
|
entity.onClauses.forEach((clause, clauseIndex) => {
|
|
133
|
-
if (clause.binding === 'every-turn' ||
|
|
398
|
+
if (clause.binding === 'every-turn' || this.eventTriggerFor(entity, clause) !== event.type)
|
|
134
399
|
return;
|
|
135
400
|
const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
|
|
136
401
|
const produced = this.fireEventClause(entity, clause, key, event, world);
|
|
@@ -141,15 +406,24 @@ class ChordRuntime {
|
|
|
141
406
|
return out;
|
|
142
407
|
}
|
|
143
408
|
fireEventClause(entity, clause, key, event, world) {
|
|
144
|
-
// The clause is about its owner
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
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).
|
|
148
421
|
return null;
|
|
422
|
+
}
|
|
149
423
|
const ctx = { world, it: entity.id };
|
|
150
424
|
if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
|
|
151
425
|
return null;
|
|
152
|
-
const occKey =
|
|
426
|
+
const occKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + key;
|
|
153
427
|
const occurrence = (world.getStateValue(occKey) ?? 0) + 1;
|
|
154
428
|
if (clause.once && occurrence > 1)
|
|
155
429
|
return null; // `, once` — one lifetime firing (D5)
|
|
@@ -163,10 +437,10 @@ class ChordRuntime {
|
|
|
163
437
|
prepareOnClauseTarget(world, entity, clause) {
|
|
164
438
|
const worldId = this.host.entityId(entity.id);
|
|
165
439
|
if (!worldId)
|
|
166
|
-
throw new
|
|
440
|
+
throw new errors_js_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, clause.span);
|
|
167
441
|
const target = world.getEntity(worldId);
|
|
168
442
|
if (!target)
|
|
169
|
-
throw new
|
|
443
|
+
throw new errors_js_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, clause.span);
|
|
170
444
|
if (!target.has(ChordBehaviorTrait.type)) {
|
|
171
445
|
target.add(new ChordBehaviorTrait());
|
|
172
446
|
}
|
|
@@ -175,17 +449,106 @@ class ChordRuntime {
|
|
|
175
449
|
target.add(new world_model_1.ReadableTrait({ text: '' }));
|
|
176
450
|
}
|
|
177
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
|
+
}
|
|
464
|
+
/**
|
|
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.
|
|
469
|
+
*/
|
|
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
|
+
}
|
|
178
525
|
/**
|
|
179
|
-
* One interceptor per action: each hook forwards to the
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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).
|
|
182
533
|
*/
|
|
183
|
-
buildDispatchingInterceptor(clauses) {
|
|
534
|
+
buildDispatchingInterceptor(action, clauses) {
|
|
184
535
|
const runtime = this;
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
});
|
|
189
552
|
const armFor = (target) => arms.find((a) => runtime.host.entityId(a.entity.id) === target.id)?.interceptor;
|
|
190
553
|
return {
|
|
191
554
|
preValidate(target, world, actorId, data) {
|
|
@@ -209,48 +572,67 @@ class ChordRuntime {
|
|
|
209
572
|
* win/lose → postReport. An `on` clause's first phrase OVERRIDES the
|
|
210
573
|
* primary message; an `after` clause's phrases APPEND (D3).
|
|
211
574
|
*/
|
|
212
|
-
buildInterceptor(entity, clause) {
|
|
575
|
+
buildInterceptor(entity, clause, ns) {
|
|
213
576
|
const runtime = this;
|
|
214
577
|
const ownWorldId = () => runtime.host.entityId(entity.id);
|
|
215
|
-
const
|
|
578
|
+
const occurrenceKey = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + `on.${ns}`;
|
|
216
579
|
return {
|
|
217
|
-
preValidate(target, world) {
|
|
580
|
+
preValidate(target, world, _actorId, data) {
|
|
218
581
|
if (target.id !== ownWorldId() || clause.clauseKind === 'after')
|
|
219
582
|
return null;
|
|
583
|
+
const bag = runtime.clauseBag(data, ns);
|
|
220
584
|
const ctx = { world, it: entity.id };
|
|
585
|
+
// D8 (ADR-228): the `while` gate is evaluated once per firing, at
|
|
586
|
+
// validate time, BEFORE findRefusal — a gated-out clause sits out
|
|
587
|
+
// entirely, refusals included. preValidate and postValidate may both
|
|
588
|
+
// evaluate the gate: no mutation occurs between them within one
|
|
589
|
+
// action, so the answers cannot differ. Do not move this evaluation.
|
|
590
|
+
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
591
|
+
bag.skip = true;
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
// `, once`: a clause that has already fired keeps its refusal out
|
|
595
|
+
// too (peek only — the occurrence bump stays in postValidate).
|
|
596
|
+
if (clause.once && (world.getStateValue(occurrenceKey) ?? 0) >= 1) {
|
|
597
|
+
bag.skip = true;
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
221
600
|
const refusal = runtime.findRefusal(clause.body, ctx);
|
|
222
601
|
return refusal ? { valid: false, error: refusal } : null;
|
|
223
602
|
},
|
|
224
603
|
postValidate(target, world, _actorId, data) {
|
|
225
604
|
if (target.id !== ownWorldId())
|
|
226
605
|
return null;
|
|
606
|
+
const bag = runtime.clauseBag(data, ns);
|
|
227
607
|
const ctx = { world, it: entity.id };
|
|
608
|
+
// D8: same gate, same evaluation point (see preValidate).
|
|
228
609
|
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
229
|
-
|
|
610
|
+
bag.skip = true; // `while <cond>` gate — clause sits out this firing
|
|
230
611
|
return null;
|
|
231
612
|
}
|
|
232
|
-
const
|
|
233
|
-
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
613
|
+
const occurrence = (world.getStateValue(occurrenceKey) ?? 0) + 1;
|
|
234
614
|
if (clause.once && occurrence > 1) {
|
|
235
|
-
|
|
615
|
+
bag.skip = true; // `, once` — one lifetime firing (D5)
|
|
236
616
|
return null;
|
|
237
617
|
}
|
|
238
|
-
world.setStateValue(
|
|
618
|
+
world.setStateValue(occurrenceKey, occurrence);
|
|
239
619
|
ctx.occurrence = occurrence;
|
|
240
|
-
|
|
241
|
-
|
|
620
|
+
bag.occurrence = occurrence;
|
|
621
|
+
bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
|
|
242
622
|
return null;
|
|
243
623
|
},
|
|
244
624
|
postExecute(target, world, _actorId, data) {
|
|
245
|
-
|
|
625
|
+
const bag = runtime.clauseBag(data, ns);
|
|
626
|
+
if (target.id !== ownWorldId() || bag.skip === true)
|
|
246
627
|
return;
|
|
247
|
-
const ctx = runtime.restoreCtx(world, entity.id,
|
|
628
|
+
const ctx = runtime.restoreCtx(world, entity.id, bag);
|
|
248
629
|
runtime.execStatements(clause.body, ctx, 'mutations');
|
|
249
630
|
},
|
|
250
631
|
postReport(target, world, _actorId, data) {
|
|
251
|
-
|
|
632
|
+
const bag = runtime.clauseBag(data, ns);
|
|
633
|
+
if (target.id !== ownWorldId() || bag.skip === true)
|
|
252
634
|
return {};
|
|
253
|
-
const ctx = runtime.restoreCtx(world, entity.id,
|
|
635
|
+
const ctx = runtime.restoreCtx(world, entity.id, bag);
|
|
254
636
|
const reports = runtime.execStatements(clause.body, ctx, 'reports');
|
|
255
637
|
const result = {};
|
|
256
638
|
const emit = [];
|
|
@@ -272,12 +654,110 @@ class ChordRuntime {
|
|
|
272
654
|
},
|
|
273
655
|
};
|
|
274
656
|
}
|
|
275
|
-
restoreCtx(world, itIrId,
|
|
657
|
+
restoreCtx(world, itIrId, bag) {
|
|
276
658
|
return {
|
|
277
659
|
world,
|
|
278
660
|
it: itIrId,
|
|
279
|
-
occurrence:
|
|
280
|
-
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
|
+
},
|
|
281
761
|
};
|
|
282
762
|
}
|
|
283
763
|
// ------------------------------------------- dispatch verbs (Phase B, §5.4)
|
|
@@ -285,7 +765,7 @@ class ChordRuntime {
|
|
|
285
765
|
writeChordTraitField(world, worldId, field, value, span) {
|
|
286
766
|
const entity = world.getEntity(worldId);
|
|
287
767
|
for (const trait of entity?.traits.values() ?? []) {
|
|
288
|
-
if (!trait.type.startsWith(
|
|
768
|
+
if (!trait.type.startsWith(state_keys_js_1.CHORD_TRAIT_PREFIX))
|
|
289
769
|
continue;
|
|
290
770
|
const record = trait;
|
|
291
771
|
if (field in record) {
|
|
@@ -293,7 +773,7 @@ class ChordRuntime {
|
|
|
293
773
|
return;
|
|
294
774
|
}
|
|
295
775
|
}
|
|
296
|
-
throw new
|
|
776
|
+
throw new errors_js_1.LoadError(`No trait on this entity carries the field \`${field}\`.`, span);
|
|
297
777
|
}
|
|
298
778
|
/**
|
|
299
779
|
* A `define trait` clause on a dispatch verb → CapabilityBehavior
|
|
@@ -313,11 +793,25 @@ class ChordRuntime {
|
|
|
313
793
|
return {
|
|
314
794
|
validate(entity, world, actorId, data) {
|
|
315
795
|
const ctx = ctxOf(entity, world, actorId, data);
|
|
796
|
+
// D8 (ADR-228): the `while` gate is evaluated once per firing, at
|
|
797
|
+
// validate time, BEFORE findRefusal — a gated-out clause sits out
|
|
798
|
+
// entirely, refusals included. ADR-229 R5: the dispatch action
|
|
799
|
+
// reads `chordSkip` as "not claiming" and falls through to the
|
|
800
|
+
// next candidate / body / miss; the execute/report guards below
|
|
801
|
+
// stay as defense in depth. Do not move this evaluation.
|
|
802
|
+
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
803
|
+
data.chordSkip = true;
|
|
804
|
+
return { valid: true };
|
|
805
|
+
}
|
|
806
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${traitName}.${clause.action}.${runtime.host.irIdOf(entity.id)}`;
|
|
807
|
+
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
808
|
+
if (clause.once && occurrence > 1) {
|
|
809
|
+
data.chordSkip = true; // `, once` — one lifetime firing (D5)
|
|
810
|
+
return { valid: true };
|
|
811
|
+
}
|
|
316
812
|
const refusal = runtime.findRefusal(clause.body, ctx);
|
|
317
813
|
if (refusal)
|
|
318
814
|
return { valid: false, error: refusal };
|
|
319
|
-
const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${traitName}.${clause.action}.${runtime.host.irIdOf(entity.id)}`;
|
|
320
|
-
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
321
815
|
world.setStateValue(key, occurrence);
|
|
322
816
|
ctx.occurrence = occurrence;
|
|
323
817
|
data.chordOccurrence = occurrence;
|
|
@@ -325,9 +819,13 @@ class ChordRuntime {
|
|
|
325
819
|
return { valid: true };
|
|
326
820
|
},
|
|
327
821
|
execute(entity, world, actorId, data) {
|
|
822
|
+
if (data.chordSkip === true)
|
|
823
|
+
return;
|
|
328
824
|
runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'mutations');
|
|
329
825
|
},
|
|
330
826
|
report(entity, world, actorId, data) {
|
|
827
|
+
if (data.chordSkip === true)
|
|
828
|
+
return [];
|
|
331
829
|
const events = runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'reports');
|
|
332
830
|
return events.map((e) => ({ type: e.type, payload: (e.data ?? {}) }));
|
|
333
831
|
},
|
|
@@ -342,33 +840,72 @@ class ChordRuntime {
|
|
|
342
840
|
* ActionInterceptor registered under the trait type (ADR-118 resolves it
|
|
343
841
|
* for every entity carrying the trait).
|
|
344
842
|
*/
|
|
345
|
-
buildTraitInterceptor(clause) {
|
|
843
|
+
buildTraitInterceptor(clause, ns) {
|
|
346
844
|
const runtime = this;
|
|
347
845
|
const itOf = (target) => runtime.host.irIdOf(target.id) ?? target.id;
|
|
846
|
+
const occurrenceKeyOf = (target) => `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait.${ns}.${itOf(target)}`;
|
|
348
847
|
return {
|
|
349
|
-
preValidate(target, world) {
|
|
350
|
-
|
|
848
|
+
preValidate(target, world, _actorId, data) {
|
|
849
|
+
if (clause.clauseKind === 'after')
|
|
850
|
+
return null;
|
|
851
|
+
const ctx = { world, it: itOf(target) };
|
|
852
|
+
// D8 (ADR-228): the `while` gate is evaluated once per firing, at
|
|
853
|
+
// validate time, BEFORE findRefusal — a gated-out clause sits out
|
|
854
|
+
// entirely, refusals included. preValidate and postValidate may both
|
|
855
|
+
// evaluate the gate: no mutation occurs between them within one
|
|
856
|
+
// action, so the answers cannot differ. Do not move this evaluation.
|
|
857
|
+
const bag = runtime.clauseBag(data, ns);
|
|
858
|
+
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
859
|
+
bag.skip = true;
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
// `, once`: a clause that has already fired keeps its refusal out
|
|
863
|
+
// too (peek only — the occurrence bump stays in postValidate).
|
|
864
|
+
if (clause.once && (world.getStateValue(occurrenceKeyOf(target)) ?? 0) >= 1) {
|
|
865
|
+
bag.skip = true;
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
const refusal = runtime.findRefusal(clause.body, ctx);
|
|
351
869
|
return refusal ? { valid: false, error: refusal } : null;
|
|
352
870
|
},
|
|
353
871
|
postValidate(target, world, _actorId, data) {
|
|
354
|
-
const
|
|
872
|
+
const bag = runtime.clauseBag(data, ns);
|
|
873
|
+
const ctx = { world, it: itOf(target) };
|
|
874
|
+
// D8: same gate, same evaluation point (see preValidate).
|
|
875
|
+
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
876
|
+
bag.skip = true; // `while <cond>` gate — clause sits out this firing
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
const key = occurrenceKeyOf(target);
|
|
355
880
|
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
881
|
+
if (clause.once && occurrence > 1) {
|
|
882
|
+
bag.skip = true; // `, once` — one lifetime firing (D5)
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
356
885
|
world.setStateValue(key, occurrence);
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
886
|
+
ctx.occurrence = occurrence;
|
|
887
|
+
bag.occurrence = occurrence;
|
|
888
|
+
bag.decisions = runtime.snapshotDecisions(clause.body, ctx);
|
|
360
889
|
return null;
|
|
361
890
|
},
|
|
362
891
|
postExecute(target, world, _actorId, data) {
|
|
363
|
-
|
|
892
|
+
const bag = runtime.clauseBag(data, ns);
|
|
893
|
+
if (bag.skip === true)
|
|
894
|
+
return;
|
|
895
|
+
runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'mutations');
|
|
364
896
|
},
|
|
365
897
|
postReport(target, world, _actorId, data) {
|
|
366
|
-
const
|
|
898
|
+
const bag = runtime.clauseBag(data, ns);
|
|
899
|
+
if (bag.skip === true)
|
|
900
|
+
return {};
|
|
901
|
+
const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), bag), 'reports');
|
|
367
902
|
const result = {};
|
|
368
903
|
const emit = [];
|
|
369
904
|
for (const event of reports) {
|
|
370
905
|
const payload = (event.data ?? {});
|
|
371
|
-
|
|
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) {
|
|
372
909
|
result.override = { messageId: String(payload.messageId), params: payload.params ?? {} };
|
|
373
910
|
}
|
|
374
911
|
else {
|
|
@@ -431,24 +968,36 @@ class ChordRuntime {
|
|
|
431
968
|
// Instance-type lookup: ChordDataTrait types are per-instance, so
|
|
432
969
|
// the constructor-static path (getBehaviorForCapability) can't see
|
|
433
970
|
// them.
|
|
971
|
+
//
|
|
972
|
+
// ADR-229 R5: a gated-out behavior does NOT claim the dispatch.
|
|
973
|
+
// A candidate whose validate returns valid with `chordSkip` set
|
|
974
|
+
// (false `while` gate, or consumed `, once` — both side-effect-free
|
|
975
|
+
// probes) is treated as if its clause were never declared: selection
|
|
976
|
+
// falls through to the next trait's behavior, the action body, or
|
|
977
|
+
// the `otherwise refuse` miss. A real refusal (valid: false) still
|
|
978
|
+
// claims immediately, exactly as before.
|
|
434
979
|
let behavior;
|
|
980
|
+
let capShared = { chordSlots: slots };
|
|
435
981
|
for (const trait of entity.traits.values()) {
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
982
|
+
const candidate = context.world.getBehaviorBinding(trait.type, actionId)?.behavior;
|
|
983
|
+
if (!candidate)
|
|
984
|
+
continue;
|
|
985
|
+
const candidateShared = { chordSlots: slots };
|
|
986
|
+
const result = candidate.validate(entity, context.world, context.player.id, candidateShared);
|
|
987
|
+
if (!result.valid)
|
|
988
|
+
return { valid: false, error: result.error };
|
|
989
|
+
if (candidateShared.chordSkip === true)
|
|
990
|
+
continue; // gated out — not claiming
|
|
991
|
+
behavior = candidate;
|
|
992
|
+
capShared = candidateShared;
|
|
993
|
+
break;
|
|
439
994
|
}
|
|
440
995
|
// A behavior host is optional when the action carries its own body
|
|
441
996
|
// (§5.4: the body IS the action's semantics — photographing has no
|
|
442
|
-
// per-trait behavior by design). No behavior AND no body =
|
|
443
|
-
// dispatch miss
|
|
997
|
+
// per-trait behavior by design). No claiming behavior AND no body =
|
|
998
|
+
// the dispatch miss.
|
|
444
999
|
if (!behavior && def.body.length === 0)
|
|
445
1000
|
return { valid: false, error: def.otherwise ?? 'cant' };
|
|
446
|
-
const capShared = { chordSlots: slots };
|
|
447
|
-
if (behavior) {
|
|
448
|
-
const result = behavior.validate(entity, context.world, context.player.id, capShared);
|
|
449
|
-
if (!result.valid)
|
|
450
|
-
return { valid: false, error: result.error };
|
|
451
|
-
}
|
|
452
1001
|
if (def.body.length) {
|
|
453
1002
|
// The body's own validate partition (leading refusals/musts) and
|
|
454
1003
|
// the pre-mutation decision snapshot (§5.4).
|
|
@@ -491,6 +1040,14 @@ class ChordRuntime {
|
|
|
491
1040
|
},
|
|
492
1041
|
blocked(context, result) {
|
|
493
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
|
+
}
|
|
494
1051
|
const event = runtime.phraseEvent(key, { world: context.world });
|
|
495
1052
|
return [context.event(event.type, (event.data ?? {}))];
|
|
496
1053
|
},
|
|
@@ -530,7 +1087,7 @@ class ChordRuntime {
|
|
|
530
1087
|
// <state>` on a state anchor (ratchet D10). Pointer and last-fired
|
|
531
1088
|
// turn live in world state — save/restore covers progression.
|
|
532
1089
|
const slug = sequence.name.replace(/\s+/g, '-');
|
|
533
|
-
const key = `${
|
|
1090
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
|
|
534
1091
|
const firedKey = `${key}.turn`;
|
|
535
1092
|
const stepReady = (step, world, turn) => {
|
|
536
1093
|
switch (step.timing) {
|
|
@@ -544,9 +1101,9 @@ class ChordRuntime {
|
|
|
544
1101
|
if (!step.anchor)
|
|
545
1102
|
return false;
|
|
546
1103
|
if (step.anchor.owner === 'story') {
|
|
547
|
-
return world.getStateValue(
|
|
1104
|
+
return world.getStateValue(state_keys_js_1.CHORD_STORY_STATE_KEY) === step.anchor.state;
|
|
548
1105
|
}
|
|
549
|
-
return world.getStateValue(
|
|
1106
|
+
return world.getStateValue(state_keys_js_1.CHORD_STATE_PREFIX + step.anchor.owner) === step.anchor.state;
|
|
550
1107
|
}
|
|
551
1108
|
}
|
|
552
1109
|
};
|
|
@@ -574,7 +1131,7 @@ class ChordRuntime {
|
|
|
574
1131
|
irEntity.onClauses.forEach((clause, clauseIndex) => {
|
|
575
1132
|
if (clause.binding !== 'every-turn')
|
|
576
1133
|
return;
|
|
577
|
-
const key = `${
|
|
1134
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}entity-turn.${irEntity.id}.${clauseIndex}`;
|
|
578
1135
|
daemons.push({
|
|
579
1136
|
id: `chord.entity-turn.${irEntity.id}.${clauseIndex}`,
|
|
580
1137
|
name: `on every turn (${irEntity.id})`,
|
|
@@ -599,6 +1156,31 @@ class ChordRuntime {
|
|
|
599
1156
|
});
|
|
600
1157
|
});
|
|
601
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
|
+
});
|
|
602
1184
|
// Every-turn trait clauses (`on every turn while …[, once]`): one
|
|
603
1185
|
// daemon per clause, evaluated per entity carrying the trait. The
|
|
604
1186
|
// composition condition (`chatty while not after-hours`) gates per
|
|
@@ -607,7 +1189,7 @@ class ChordRuntime {
|
|
|
607
1189
|
trait.onClauses.forEach((clause, clauseIndex) => {
|
|
608
1190
|
if (clause.binding !== 'every-turn')
|
|
609
1191
|
return;
|
|
610
|
-
const traitType =
|
|
1192
|
+
const traitType = state_keys_js_1.CHORD_TRAIT_PREFIX + trait.name;
|
|
611
1193
|
daemons.push({
|
|
612
1194
|
id: `chord.trait-turn.${trait.name}.${clauseIndex}`,
|
|
613
1195
|
name: `on every turn (${trait.name})`,
|
|
@@ -630,7 +1212,7 @@ class ChordRuntime {
|
|
|
630
1212
|
continue;
|
|
631
1213
|
if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
|
|
632
1214
|
continue;
|
|
633
|
-
const key = `${
|
|
1215
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}trait-turn.${trait.name}.${clauseIndex}.${irEntity.id}`;
|
|
634
1216
|
const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
|
|
635
1217
|
if (clause.once && fired > 1)
|
|
636
1218
|
continue; // `, once` (D5)
|
|
@@ -663,11 +1245,23 @@ class ChordRuntime {
|
|
|
663
1245
|
narrated(events) {
|
|
664
1246
|
return events.map((e) => ({ ...e, narrate: true }));
|
|
665
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
|
+
}
|
|
666
1258
|
/**
|
|
667
1259
|
* Decision 10 presence semantics: the player shares the owner's location.
|
|
668
|
-
* A room owner means the player is IN that room;
|
|
669
|
-
*
|
|
670
|
-
*
|
|
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).
|
|
671
1265
|
*/
|
|
672
1266
|
playerPresentAt(world, irEntityId) {
|
|
673
1267
|
const ownerId = this.host.entityId(irEntityId);
|
|
@@ -676,8 +1270,10 @@ class ChordRuntime {
|
|
|
676
1270
|
return false;
|
|
677
1271
|
if (ownerId === playerId)
|
|
678
1272
|
return true;
|
|
679
|
-
const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
|
|
680
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);
|
|
681
1277
|
if (owner?.has(world_model_1.TraitType.ROOM))
|
|
682
1278
|
return playerRoom === ownerId;
|
|
683
1279
|
const ownerRoom = world.getContainingRoom(ownerId)?.id ?? world.getLocation(ownerId);
|
|
@@ -703,7 +1299,7 @@ class ChordRuntime {
|
|
|
703
1299
|
const ctx = { world, it: targetIrId };
|
|
704
1300
|
if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
|
|
705
1301
|
return;
|
|
706
|
-
const key = `${
|
|
1302
|
+
const key = `${state_keys_js_1.CHORD_OCCURRENCE_PREFIX}after.${irEntity.id}.${actionName}.${clauseIndex}`;
|
|
707
1303
|
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
708
1304
|
if (clause.once && occurrence > 1)
|
|
709
1305
|
return; // `, once` (D5)
|
|
@@ -725,49 +1321,53 @@ class ChordRuntime {
|
|
|
725
1321
|
}
|
|
726
1322
|
return out;
|
|
727
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.
|
|
728
1327
|
/**
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
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.
|
|
732
1333
|
*/
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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;
|
|
759
1368
|
}
|
|
760
1369
|
}
|
|
761
1370
|
}
|
|
762
|
-
/** Single-variant text of a blocked-exit phrase (mirrors the loader's read). */
|
|
763
|
-
blockedPhraseText(key) {
|
|
764
|
-
const phrase = this.ir.phrases.locales[this.ir.phrases.defaultLocale]?.[key];
|
|
765
|
-
return phrase?.variants[0]?.text ?? '';
|
|
766
|
-
}
|
|
767
|
-
/** True when any entity declares a conditional blocked exit. */
|
|
768
|
-
hasConditionalBlockedExits() {
|
|
769
|
-
return this.ir.entities.some((e) => e.blockedExits.some((b) => b.condition !== null));
|
|
770
|
-
}
|
|
771
1371
|
// ------------------------------------------------------------ statements
|
|
772
1372
|
/**
|
|
773
1373
|
* Execute a statement tree. `phase` narrows which leaves act:
|
|
@@ -788,8 +1388,15 @@ class ChordRuntime {
|
|
|
788
1388
|
events.push(this.phraseEvent(stmt.phraseKey, ctx, stmt.params));
|
|
789
1389
|
break;
|
|
790
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.
|
|
791
1398
|
if (phase !== 'mutations' && whenHolds(stmt))
|
|
792
|
-
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)));
|
|
793
1400
|
break;
|
|
794
1401
|
case 'win':
|
|
795
1402
|
case 'lose':
|
|
@@ -799,20 +1406,40 @@ class ChordRuntime {
|
|
|
799
1406
|
events.push(this.host.triggerEnding(ctx.world, stmt.kind === 'win' ? 'victory' : 'defeat', stmt.phraseKey ?? undefined));
|
|
800
1407
|
}
|
|
801
1408
|
break;
|
|
1409
|
+
case 'kill':
|
|
1410
|
+
// `kill the player` (ADR-227 Decision 4): terminal death via the
|
|
1411
|
+
// platform's killPlayer sink — the engine routes game-over off the
|
|
1412
|
+
// canonical if.event.player.died it returns; triggerEnding is NOT
|
|
1413
|
+
// called (a distinct lowering target from win/lose). The phrase
|
|
1414
|
+
// carries the death text; the cause derives from the phrase key.
|
|
1415
|
+
if (phase !== 'mutations' && whenHolds(stmt)) {
|
|
1416
|
+
if (stmt.phraseKey)
|
|
1417
|
+
events.push(this.phraseEvent(stmt.phraseKey, ctx));
|
|
1418
|
+
const player = ctx.world.getPlayer();
|
|
1419
|
+
if (player) {
|
|
1420
|
+
const died = (0, stdlib_1.killPlayer)(ctx.world, player, {
|
|
1421
|
+
cause: stmt.phraseKey ?? 'killed',
|
|
1422
|
+
terminal: true,
|
|
1423
|
+
});
|
|
1424
|
+
if (died)
|
|
1425
|
+
events.push(died);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
break;
|
|
802
1429
|
case 'change': {
|
|
803
1430
|
if (phase !== 'reports' && whenHolds(stmt)) {
|
|
804
1431
|
if (stmt.entity.kind === 'story') {
|
|
805
1432
|
// `change the story to <state>` — the story object's phase (D2).
|
|
806
|
-
this.checkForwardMarch(this.ir.story.states, this.ir.story.reversible, ctx.world.getStateValue(
|
|
807
|
-
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);
|
|
808
1435
|
}
|
|
809
1436
|
else {
|
|
810
1437
|
const irId = this.irIdOfValue(stmt.entity, ctx);
|
|
811
1438
|
const set = this.stateSetOf(irId, stmt.state);
|
|
812
1439
|
if (set) {
|
|
813
|
-
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);
|
|
814
1441
|
}
|
|
815
|
-
ctx.world.setStateValue(
|
|
1442
|
+
ctx.world.setStateValue(state_keys_js_1.CHORD_STATE_PREFIX + irId, stmt.state);
|
|
816
1443
|
}
|
|
817
1444
|
}
|
|
818
1445
|
break;
|
|
@@ -846,7 +1473,7 @@ class ChordRuntime {
|
|
|
846
1473
|
this.writeChordTraitField(ctx.world, baseId, stmt.target.field, value, stmt.span);
|
|
847
1474
|
}
|
|
848
1475
|
else {
|
|
849
|
-
throw new
|
|
1476
|
+
throw new errors_js_1.LoadError('`set` targets a trait data field.', stmt.span);
|
|
850
1477
|
}
|
|
851
1478
|
}
|
|
852
1479
|
break;
|
|
@@ -857,12 +1484,12 @@ class ChordRuntime {
|
|
|
857
1484
|
// awards are no-ops and `, once` is automatic. Names arrive
|
|
858
1485
|
// owner-qualified from the analyzer (ratchet D12).
|
|
859
1486
|
if (stmt.expression.length !== 1) {
|
|
860
|
-
throw new
|
|
1487
|
+
throw new errors_js_1.LoadError('Only `award <score-name>` is supported (expression awards are later scope).', stmt.span);
|
|
861
1488
|
}
|
|
862
1489
|
const name = stmt.expression[0];
|
|
863
1490
|
const worth = this.scoreWorth.get(name);
|
|
864
1491
|
if (worth === undefined) {
|
|
865
|
-
throw new
|
|
1492
|
+
throw new errors_js_1.LoadError(`\`${name}\` is not a declared score.`, stmt.span);
|
|
866
1493
|
}
|
|
867
1494
|
ctx.world.awardScore(name, worth, name);
|
|
868
1495
|
}
|
|
@@ -988,7 +1615,7 @@ class ChordRuntime {
|
|
|
988
1615
|
const from = states.indexOf(current);
|
|
989
1616
|
const to = states.indexOf(target);
|
|
990
1617
|
if (from >= 0 && to >= 0 && to < from) {
|
|
991
|
-
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);
|
|
992
1619
|
}
|
|
993
1620
|
}
|
|
994
1621
|
/**
|
|
@@ -1000,21 +1627,33 @@ class ChordRuntime {
|
|
|
1000
1627
|
findRefusal(body, ctx) {
|
|
1001
1628
|
for (const stmt of body) {
|
|
1002
1629
|
if (stmt.kind === 'refuse')
|
|
1003
|
-
return stmt.phraseKey;
|
|
1630
|
+
return this.resolvePhraseKey(stmt.phraseKey, ctx);
|
|
1004
1631
|
if (stmt.kind === 'must') {
|
|
1005
1632
|
if (!this.evaluator.evalCondition(stmt.condition, ctx))
|
|
1006
|
-
return stmt.phraseKey;
|
|
1633
|
+
return this.resolvePhraseKey(stmt.phraseKey, ctx);
|
|
1007
1634
|
continue;
|
|
1008
1635
|
}
|
|
1009
1636
|
if (stmt.kind === 'refuse-when') {
|
|
1010
1637
|
if (this.evaluator.evalCondition(stmt.condition, ctx))
|
|
1011
|
-
return stmt.phraseKey;
|
|
1638
|
+
return this.resolvePhraseKey(stmt.phraseKey, ctx);
|
|
1012
1639
|
continue;
|
|
1013
1640
|
}
|
|
1014
1641
|
break; // first non-refusal statement ends the validate partition
|
|
1015
1642
|
}
|
|
1016
1643
|
return null;
|
|
1017
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
|
+
}
|
|
1018
1657
|
/**
|
|
1019
1658
|
* Snapshot every branching decision (`if` and `select-on`) before
|
|
1020
1659
|
* mutations run, walking only the branches actually taken (§5.4: the
|
|
@@ -1081,7 +1720,7 @@ class ChordRuntime {
|
|
|
1081
1720
|
// the persisted chance stream (via one draw per firing). Sticky (Z5)
|
|
1082
1721
|
// reuses the same slot with the Choice encoding instead of an
|
|
1083
1722
|
// occurrence count: stored = chosen index + 1, 0/undefined = unchosen.
|
|
1084
|
-
const key =
|
|
1723
|
+
const key = state_keys_js_1.CHORD_OCCURRENCE_PREFIX + `select.${stmt.span.line}`;
|
|
1085
1724
|
if (stmt.strategy === 'sticky') {
|
|
1086
1725
|
const stored = ctx.world.getStateValue(key);
|
|
1087
1726
|
if (stored && stored > 0)
|
|
@@ -1102,7 +1741,7 @@ class ChordRuntime {
|
|
|
1102
1741
|
case 'randomly':
|
|
1103
1742
|
return this.randomIndex(count, ctx);
|
|
1104
1743
|
default:
|
|
1105
|
-
throw new
|
|
1744
|
+
throw new errors_js_1.LoadError(`Unknown select strategy \`${stmt.strategy}\`.`, stmt.span);
|
|
1106
1745
|
}
|
|
1107
1746
|
}
|
|
1108
1747
|
randomIndex(count, ctx) {
|
|
@@ -1159,8 +1798,14 @@ class ChordRuntime {
|
|
|
1159
1798
|
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
1160
1799
|
const overrideKey = ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
|
|
1161
1800
|
const phrase = table[overrideKey];
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
+
}
|
|
1164
1809
|
const params = {};
|
|
1165
1810
|
// Authored `with <param> = <value>` bindings (zoo-chain follow-up,
|
|
1166
1811
|
// 2026-07-12): entity values pass as their display name (the template's
|
|
@@ -1170,7 +1815,7 @@ class ChordRuntime {
|
|
|
1170
1815
|
const asEntity = typeof value === 'string' ? ctx.world.getEntity(value) : undefined;
|
|
1171
1816
|
params[p.param] = asEntity ? asEntity.name : value;
|
|
1172
1817
|
}
|
|
1173
|
-
for (const variant of phrase.variants) {
|
|
1818
|
+
for (const variant of phrase ? phrase.variants : bookVariants) {
|
|
1174
1819
|
for (const marker of variant.markers) {
|
|
1175
1820
|
const producer = this.host.producers.get(marker);
|
|
1176
1821
|
if (producer) {
|
|
@@ -1181,10 +1826,10 @@ class ChordRuntime {
|
|
|
1181
1826
|
// producer reaching outside it fails HERE, named, not as an
|
|
1182
1827
|
// anonymous TypeError downstream.
|
|
1183
1828
|
try {
|
|
1184
|
-
params[marker] = producer((0,
|
|
1829
|
+
params[marker] = producer((0, hatch_context_js_1.stagingRenderContext)(ctx.world));
|
|
1185
1830
|
}
|
|
1186
1831
|
catch (error) {
|
|
1187
|
-
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).`);
|
|
1188
1833
|
}
|
|
1189
1834
|
}
|
|
1190
1835
|
}
|
|
@@ -1202,16 +1847,16 @@ class ChordRuntime {
|
|
|
1202
1847
|
params[name] = slotEntity.name;
|
|
1203
1848
|
}
|
|
1204
1849
|
}
|
|
1205
|
-
if (phrase
|
|
1850
|
+
if (phrase?.verbatim) {
|
|
1206
1851
|
// `{verbatim:text}` template (loader registration) — the atom is
|
|
1207
1852
|
// exempt from whitespace collapse, so line structure and interior
|
|
1208
1853
|
// spacing survive as authored (grammar log 2026-07-10).
|
|
1209
1854
|
params.text = phrase.variants[0]?.text ?? '';
|
|
1210
1855
|
}
|
|
1211
|
-
else if (phrase
|
|
1856
|
+
else if (phrase?.strategy) {
|
|
1212
1857
|
const choice = {
|
|
1213
1858
|
kind: 'choice',
|
|
1214
|
-
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) })),
|
|
1215
1860
|
selector: exports.STRATEGY_SELECTOR[phrase.strategy],
|
|
1216
1861
|
entityId: counter?.entityId ?? 'chord',
|
|
1217
1862
|
messageKey: counter?.messageKey ?? overrideKey,
|
|
@@ -1220,6 +1865,38 @@ class ChordRuntime {
|
|
|
1220
1865
|
}
|
|
1221
1866
|
return this.rawEvent('chord.phrase', { messageId: overrideKey, params });
|
|
1222
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
|
+
}
|
|
1223
1900
|
rawEvent(type, data) {
|
|
1224
1901
|
return {
|
|
1225
1902
|
id: `chord-${type}-${this.eventSeq++}`,
|
|
@@ -1235,13 +1912,13 @@ class ChordRuntime {
|
|
|
1235
1912
|
return value.id;
|
|
1236
1913
|
if (value.kind === 'it') {
|
|
1237
1914
|
if (!ctx.it)
|
|
1238
|
-
throw new
|
|
1915
|
+
throw new errors_js_1.LoadError('`it` used outside an entity-scoped clause.');
|
|
1239
1916
|
return ctx.it;
|
|
1240
1917
|
}
|
|
1241
1918
|
const worldId = this.evaluator.entityValue(value, ctx);
|
|
1242
1919
|
const irId = this.host.irIdOf(worldId);
|
|
1243
1920
|
if (!irId)
|
|
1244
|
-
throw new
|
|
1921
|
+
throw new errors_js_1.LoadError('Cannot change the state of a non-story entity.');
|
|
1245
1922
|
return irId;
|
|
1246
1923
|
}
|
|
1247
1924
|
}
|