@sharpee/story-loader 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/errors.d.ts +20 -0
- package/errors.d.ts.map +1 -0
- package/errors.js +16 -0
- package/errors.js.map +1 -0
- package/evaluator.d.ts +102 -0
- package/evaluator.d.ts.map +1 -0
- package/evaluator.js +384 -0
- package/evaluator.js.map +1 -0
- package/event-contract.d.ts +39 -0
- package/event-contract.d.ts.map +1 -0
- package/event-contract.js +35 -0
- package/event-contract.js.map +1 -0
- package/hatch-context.d.ts +57 -0
- package/hatch-context.d.ts.map +1 -0
- package/hatch-context.js +92 -0
- package/hatch-context.js.map +1 -0
- package/index.d.ts +24 -0
- package/index.d.ts.map +1 -0
- package/index.js +44 -0
- package/index.js.map +1 -0
- package/loader.d.ts +241 -0
- package/loader.d.ts.map +1 -0
- package/loader.js +923 -0
- package/loader.js.map +1 -0
- package/package.json +50 -0
- package/runtime.d.ts +250 -0
- package/runtime.d.ts.map +1 -0
- package/runtime.js +1249 -0
- package/runtime.js.map +1 -0
- package/state-keys.d.ts +23 -0
- package/state-keys.d.ts.map +1 -0
- package/state-keys.js +26 -0
- package/state-keys.js.map +1 -0
- package/text.d.ts +19 -0
- package/text.d.ts.map +1 -0
- package/text.js +24 -0
- package/text.js.map +1 -0
package/runtime.js
ADDED
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChordRuntime = exports.ChordBehaviorTrait = exports.STRATEGY_SELECTOR = void 0;
|
|
4
|
+
const world_model_1 = require("@sharpee/world-model");
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
const state_keys_1 = require("./state-keys");
|
|
7
|
+
const text_1 = require("./text");
|
|
8
|
+
const hatch_context_1 = require("./hatch-context");
|
|
9
|
+
const event_contract_1 = require("./event-contract");
|
|
10
|
+
/**
|
|
11
|
+
* Chord strategy adverb → phrase-algebra Choice selector (ADR-196).
|
|
12
|
+
* The Z5 table (ADR-211 Decision 4): adverbs mirror the selectors 1:1;
|
|
13
|
+
* `ordered`/`once` are retired at parse time and never reach here.
|
|
14
|
+
* Exported as the single implementation (ratchet Z5) — the loader's Z2
|
|
15
|
+
* snippet compile maps the same adverbs onto `SnippetEntry.selector`.
|
|
16
|
+
*/
|
|
17
|
+
exports.STRATEGY_SELECTOR = {
|
|
18
|
+
randomly: 'random',
|
|
19
|
+
cycling: 'cycling',
|
|
20
|
+
stopping: 'stopping',
|
|
21
|
+
sticky: 'sticky',
|
|
22
|
+
'first-time': 'firstTime',
|
|
23
|
+
};
|
|
24
|
+
/** Marker trait carried by entities with compiled `on` clauses. */
|
|
25
|
+
class ChordBehaviorTrait {
|
|
26
|
+
static type = 'chord.behavior';
|
|
27
|
+
type = ChordBehaviorTrait.type;
|
|
28
|
+
}
|
|
29
|
+
exports.ChordBehaviorTrait = ChordBehaviorTrait;
|
|
30
|
+
class ChordRuntime {
|
|
31
|
+
ir;
|
|
32
|
+
host;
|
|
33
|
+
evaluator;
|
|
34
|
+
eventSeq = 0;
|
|
35
|
+
/** Declared score identities (Phase B): name → worth. */
|
|
36
|
+
scoreWorth = new Map();
|
|
37
|
+
constructor(ir, host, evaluator) {
|
|
38
|
+
this.ir = ir;
|
|
39
|
+
this.host = host;
|
|
40
|
+
this.evaluator = evaluator;
|
|
41
|
+
for (const score of ir.scores)
|
|
42
|
+
this.scoreWorth.set(score.name, score.worth);
|
|
43
|
+
}
|
|
44
|
+
// ------------------------------------------------------------------ bind
|
|
45
|
+
/** Register on/after clauses, event clauses, and derived-property chains. */
|
|
46
|
+
bind(world) {
|
|
47
|
+
// The interceptor registry is keyed (traitType, actionId) — a second
|
|
48
|
+
// registration for the same action would REPLACE the first, silently
|
|
49
|
+
// disabling earlier entities' clauses. Group clauses by action and
|
|
50
|
+
// register one dispatching interceptor per action that routes by the
|
|
51
|
+
// action's target entity.
|
|
52
|
+
const byAction = new Map();
|
|
53
|
+
for (const entity of this.ir.entities) {
|
|
54
|
+
entity.onClauses.forEach((clause, clauseIndex) => {
|
|
55
|
+
// Entity every-turn clauses are scheduler daemons, not interceptors.
|
|
56
|
+
if (clause.binding === 'every-turn')
|
|
57
|
+
return;
|
|
58
|
+
// Event clauses (`after entering it`) bind to the event stream per
|
|
59
|
+
// the selector contract — the ownership package's replacement for
|
|
60
|
+
// floating `when` rules.
|
|
61
|
+
if (event_contract_1.EVENT_TRIGGERS[clause.action]) {
|
|
62
|
+
this.bindEventClause(world, entity, clause, clauseIndex);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.prepareOnClauseTarget(world, entity, clause);
|
|
66
|
+
const list = byAction.get(clause.action) ?? [];
|
|
67
|
+
list.push({ entity, clause });
|
|
68
|
+
byAction.set(clause.action, list);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
for (const [action, clauses] of byAction) {
|
|
72
|
+
world.registerActionInterceptor(ChordBehaviorTrait.type, `if.action.${action}`, this.buildDispatchingInterceptor(clauses));
|
|
73
|
+
}
|
|
74
|
+
// Phase B: `define trait` clauses register per TRAIT TYPE — capability
|
|
75
|
+
// behaviors for dispatch verbs, interceptors for standard-semantics
|
|
76
|
+
// actions (§5.4 routing recorded on the IR by the analyzer).
|
|
77
|
+
for (const trait of this.ir.traits) {
|
|
78
|
+
const traitType = state_keys_1.CHORD_TRAIT_PREFIX + trait.name;
|
|
79
|
+
for (const clause of trait.onClauses) {
|
|
80
|
+
if (clause.binding === 'every-turn')
|
|
81
|
+
continue; // scheduler phase (plan phase 5)
|
|
82
|
+
if (clause.binding === 'role') {
|
|
83
|
+
throw new errors_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
|
+
}
|
|
85
|
+
if (clause.routing === 'capability') {
|
|
86
|
+
world.registerCapabilityBehavior(traitType, `chord.action.${clause.action}`, this.buildCapabilityBehavior(trait.name, clause));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
world.registerActionInterceptor(traitType, `if.action.${clause.action}`, this.buildTraitInterceptor(clause));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Derived properties (`dark while`, `is blocked while`) — recompute
|
|
94
|
+
// when possession, location, or openable/switchable state changes.
|
|
95
|
+
if (this.derivedDarkRooms().length > 0 || this.hasConditionalBlockedExits()) {
|
|
96
|
+
for (const trigger of [
|
|
97
|
+
'if.event.taken',
|
|
98
|
+
'if.event.dropped',
|
|
99
|
+
'if.event.worn',
|
|
100
|
+
'if.event.removed',
|
|
101
|
+
'if.event.put_on',
|
|
102
|
+
'if.event.put_in',
|
|
103
|
+
'if.event.actor_moved',
|
|
104
|
+
'if.event.opened',
|
|
105
|
+
'if.event.closed',
|
|
106
|
+
'if.event.switched_on',
|
|
107
|
+
'if.event.switched_off',
|
|
108
|
+
]) {
|
|
109
|
+
world.chainEvent(trigger, (_event, w) => {
|
|
110
|
+
this.recomputeDerived(w);
|
|
111
|
+
return null;
|
|
112
|
+
}, { key: `chord.derived.${trigger}`, priority: 100 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------- event clauses
|
|
117
|
+
/**
|
|
118
|
+
* Bind an event clause (`after entering it` on a room) to its trigger
|
|
119
|
+
* event per the selector contract — the ownership package's replacement
|
|
120
|
+
* for floating `when` rules: the same firing semantics, owned by the
|
|
121
|
+
* entity the event is about.
|
|
122
|
+
*/
|
|
123
|
+
bindEventClause(world, entity, clause, clauseIndex) {
|
|
124
|
+
const trigger = event_contract_1.EVENT_TRIGGERS[clause.action];
|
|
125
|
+
const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
|
|
126
|
+
world.chainEvent(trigger, (event, w) => this.fireEventClause(entity, clause, key, event, w), { key });
|
|
127
|
+
}
|
|
128
|
+
/** Test/debug entry: run every event clause bound to this event type. */
|
|
129
|
+
fireEventClauses(world, event) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const entity of this.ir.entities) {
|
|
132
|
+
entity.onClauses.forEach((clause, clauseIndex) => {
|
|
133
|
+
if (clause.binding === 'every-turn' || event_contract_1.EVENT_TRIGGERS[clause.action] !== event.type)
|
|
134
|
+
return;
|
|
135
|
+
const key = `chord.clause.${entity.id}.${clause.action}.${clauseIndex}`;
|
|
136
|
+
const produced = this.fireEventClause(entity, clause, key, event, world);
|
|
137
|
+
if (produced)
|
|
138
|
+
out.push(...produced);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
fireEventClause(entity, clause, key, event, world) {
|
|
144
|
+
// The clause is about its owner: `after entering it` fires when the
|
|
145
|
+
// movement's destination IS the owner — read through the AC-9 payload
|
|
146
|
+
// guard, never a blind cast (the stdlib event is a foreign surface).
|
|
147
|
+
if (clause.action === 'entering' && (0, event_contract_1.enteringDestination)(event.data) !== this.host.entityId(entity.id))
|
|
148
|
+
return null;
|
|
149
|
+
const ctx = { world, it: entity.id };
|
|
150
|
+
if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
|
|
151
|
+
return null;
|
|
152
|
+
const occKey = state_keys_1.CHORD_OCCURRENCE_PREFIX + key;
|
|
153
|
+
const occurrence = (world.getStateValue(occKey) ?? 0) + 1;
|
|
154
|
+
if (clause.once && occurrence > 1)
|
|
155
|
+
return null; // `, once` — one lifetime firing (D5)
|
|
156
|
+
world.setStateValue(occKey, occurrence);
|
|
157
|
+
ctx.occurrence = occurrence;
|
|
158
|
+
ctx.decisions = this.snapshotDecisions(clause.body, ctx);
|
|
159
|
+
return this.execStatements(clause.body, ctx);
|
|
160
|
+
}
|
|
161
|
+
// ------------------------------------------------------------ on-clauses
|
|
162
|
+
/** Mark the clause's target entity so interceptor resolution finds it. */
|
|
163
|
+
prepareOnClauseTarget(world, entity, clause) {
|
|
164
|
+
const worldId = this.host.entityId(entity.id);
|
|
165
|
+
if (!worldId)
|
|
166
|
+
throw new errors_1.LoadError(`Entity \`${entity.id}\` has no world instance.`, clause.span);
|
|
167
|
+
const target = world.getEntity(worldId);
|
|
168
|
+
if (!target)
|
|
169
|
+
throw new errors_1.LoadError(`Entity \`${entity.id}\` vanished before binding.`, clause.span);
|
|
170
|
+
if (!target.has(ChordBehaviorTrait.type)) {
|
|
171
|
+
target.add(new ChordBehaviorTrait());
|
|
172
|
+
}
|
|
173
|
+
// `on reading it` targets must satisfy the reading action's trait gate.
|
|
174
|
+
if (clause.action === 'reading' && !target.has(world_model_1.TraitType.READABLE)) {
|
|
175
|
+
target.add(new world_model_1.ReadableTrait({ text: '' }));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* One interceptor per action: each hook forwards to the clause whose
|
|
180
|
+
* entity is the action's target (per-clause interceptors keep their own
|
|
181
|
+
* occurrence keys and decision snapshots).
|
|
182
|
+
*/
|
|
183
|
+
buildDispatchingInterceptor(clauses) {
|
|
184
|
+
const runtime = this;
|
|
185
|
+
const arms = clauses.map(({ entity, clause }) => ({
|
|
186
|
+
entity,
|
|
187
|
+
interceptor: this.buildInterceptor(entity, clause),
|
|
188
|
+
}));
|
|
189
|
+
const armFor = (target) => arms.find((a) => runtime.host.entityId(a.entity.id) === target.id)?.interceptor;
|
|
190
|
+
return {
|
|
191
|
+
preValidate(target, world, actorId, data) {
|
|
192
|
+
return armFor(target)?.preValidate?.(target, world, actorId, data) ?? null;
|
|
193
|
+
},
|
|
194
|
+
postValidate(target, world, actorId, data) {
|
|
195
|
+
return armFor(target)?.postValidate?.(target, world, actorId, data) ?? null;
|
|
196
|
+
},
|
|
197
|
+
postExecute(target, world, actorId, data) {
|
|
198
|
+
armFor(target)?.postExecute?.(target, world, actorId, data);
|
|
199
|
+
},
|
|
200
|
+
postReport(target, world, actorId, data) {
|
|
201
|
+
return armFor(target)?.postReport?.(target, world, actorId, data) ?? {};
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Compile one `on`/`after` clause to an ActionInterceptor via the §5.4
|
|
207
|
+
* partition: leading refusals → preValidate (`on` only — `after` reacts
|
|
208
|
+
* and cannot refuse, ratchet D3); mutations → postExecute; phrase/emit/
|
|
209
|
+
* win/lose → postReport. An `on` clause's first phrase OVERRIDES the
|
|
210
|
+
* primary message; an `after` clause's phrases APPEND (D3).
|
|
211
|
+
*/
|
|
212
|
+
buildInterceptor(entity, clause) {
|
|
213
|
+
const runtime = this;
|
|
214
|
+
const ownWorldId = () => runtime.host.entityId(entity.id);
|
|
215
|
+
const skipped = (data) => data.chordSkip === true;
|
|
216
|
+
return {
|
|
217
|
+
preValidate(target, world) {
|
|
218
|
+
if (target.id !== ownWorldId() || clause.clauseKind === 'after')
|
|
219
|
+
return null;
|
|
220
|
+
const ctx = { world, it: entity.id };
|
|
221
|
+
const refusal = runtime.findRefusal(clause.body, ctx);
|
|
222
|
+
return refusal ? { valid: false, error: refusal } : null;
|
|
223
|
+
},
|
|
224
|
+
postValidate(target, world, _actorId, data) {
|
|
225
|
+
if (target.id !== ownWorldId())
|
|
226
|
+
return null;
|
|
227
|
+
const ctx = { world, it: entity.id };
|
|
228
|
+
if (clause.condition && !runtime.evaluator.evalCondition(clause.condition, ctx)) {
|
|
229
|
+
data.chordSkip = true; // `while <cond>` gate — clause sits out this firing
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const key = state_keys_1.CHORD_OCCURRENCE_PREFIX + `on.${entity.id}.${clause.action}`;
|
|
233
|
+
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
234
|
+
if (clause.once && occurrence > 1) {
|
|
235
|
+
data.chordSkip = true; // `, once` — one lifetime firing (D5)
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
world.setStateValue(key, occurrence);
|
|
239
|
+
ctx.occurrence = occurrence;
|
|
240
|
+
data.chordOccurrence = occurrence;
|
|
241
|
+
data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
|
|
242
|
+
return null;
|
|
243
|
+
},
|
|
244
|
+
postExecute(target, world, _actorId, data) {
|
|
245
|
+
if (target.id !== ownWorldId() || skipped(data))
|
|
246
|
+
return;
|
|
247
|
+
const ctx = runtime.restoreCtx(world, entity.id, data);
|
|
248
|
+
runtime.execStatements(clause.body, ctx, 'mutations');
|
|
249
|
+
},
|
|
250
|
+
postReport(target, world, _actorId, data) {
|
|
251
|
+
if (target.id !== ownWorldId() || skipped(data))
|
|
252
|
+
return {};
|
|
253
|
+
const ctx = runtime.restoreCtx(world, entity.id, data);
|
|
254
|
+
const reports = runtime.execStatements(clause.body, ctx, 'reports');
|
|
255
|
+
const result = {};
|
|
256
|
+
const emit = [];
|
|
257
|
+
for (const event of reports) {
|
|
258
|
+
const payload = (event.data ?? {});
|
|
259
|
+
if (clause.clauseKind === 'on' && event.type === 'chord.phrase' && !result.override) {
|
|
260
|
+
result.override = {
|
|
261
|
+
messageId: String(payload.messageId),
|
|
262
|
+
params: payload.params ?? {},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
emit.push({ type: event.type, payload });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (emit.length)
|
|
270
|
+
result.emit = emit;
|
|
271
|
+
return result;
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
restoreCtx(world, itIrId, data) {
|
|
276
|
+
return {
|
|
277
|
+
world,
|
|
278
|
+
it: itIrId,
|
|
279
|
+
occurrence: data.chordOccurrence,
|
|
280
|
+
decisions: data.chordDecisions,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// ------------------------------------------- dispatch verbs (Phase B, §5.4)
|
|
284
|
+
/** Write a `define trait` data field on the entity's chord trait instance. */
|
|
285
|
+
writeChordTraitField(world, worldId, field, value, span) {
|
|
286
|
+
const entity = world.getEntity(worldId);
|
|
287
|
+
for (const trait of entity?.traits.values() ?? []) {
|
|
288
|
+
if (!trait.type.startsWith(state_keys_1.CHORD_TRAIT_PREFIX))
|
|
289
|
+
continue;
|
|
290
|
+
const record = trait;
|
|
291
|
+
if (field in record) {
|
|
292
|
+
record[field] = typeof value === 'boolean' ? String(value) : value;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
throw new errors_1.LoadError(`No trait on this entity carries the field \`${field}\`.`, span);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* A `define trait` clause on a dispatch verb → CapabilityBehavior
|
|
300
|
+
* (§5.4's second half): refusal scan in validate (with the occurrence
|
|
301
|
+
* bump and decision snapshot stashed in sharedData), mutations in
|
|
302
|
+
* execute, phrase/emit/win/lose in report.
|
|
303
|
+
*/
|
|
304
|
+
buildCapabilityBehavior(traitName, clause) {
|
|
305
|
+
const runtime = this;
|
|
306
|
+
const ctxOf = (entity, world, actorId, data) => ({
|
|
307
|
+
world,
|
|
308
|
+
it: runtime.host.irIdOf(entity.id),
|
|
309
|
+
slots: { ...data.chordSlots, actor: actorId },
|
|
310
|
+
occurrence: data.chordOccurrence,
|
|
311
|
+
decisions: data.chordDecisions,
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
validate(entity, world, actorId, data) {
|
|
315
|
+
const ctx = ctxOf(entity, world, actorId, data);
|
|
316
|
+
const refusal = runtime.findRefusal(clause.body, ctx);
|
|
317
|
+
if (refusal)
|
|
318
|
+
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
|
+
world.setStateValue(key, occurrence);
|
|
322
|
+
ctx.occurrence = occurrence;
|
|
323
|
+
data.chordOccurrence = occurrence;
|
|
324
|
+
data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
|
|
325
|
+
return { valid: true };
|
|
326
|
+
},
|
|
327
|
+
execute(entity, world, actorId, data) {
|
|
328
|
+
runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'mutations');
|
|
329
|
+
},
|
|
330
|
+
report(entity, world, actorId, data) {
|
|
331
|
+
const events = runtime.execStatements(clause.body, ctxOf(entity, world, actorId, data), 'reports');
|
|
332
|
+
return events.map((e) => ({ type: e.type, payload: (e.data ?? {}) }));
|
|
333
|
+
},
|
|
334
|
+
blocked(entity, world, actorId, error, data) {
|
|
335
|
+
const event = runtime.phraseEvent(error, ctxOf(entity, world, actorId, data));
|
|
336
|
+
return [{ type: event.type, payload: (event.data ?? {}) }];
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* A `define trait` clause on a standard-semantics action → one
|
|
342
|
+
* ActionInterceptor registered under the trait type (ADR-118 resolves it
|
|
343
|
+
* for every entity carrying the trait).
|
|
344
|
+
*/
|
|
345
|
+
buildTraitInterceptor(clause) {
|
|
346
|
+
const runtime = this;
|
|
347
|
+
const itOf = (target) => runtime.host.irIdOf(target.id) ?? target.id;
|
|
348
|
+
return {
|
|
349
|
+
preValidate(target, world) {
|
|
350
|
+
const refusal = runtime.findRefusal(clause.body, { world, it: itOf(target) });
|
|
351
|
+
return refusal ? { valid: false, error: refusal } : null;
|
|
352
|
+
},
|
|
353
|
+
postValidate(target, world, _actorId, data) {
|
|
354
|
+
const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait.${clause.action}.${itOf(target)}`;
|
|
355
|
+
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
356
|
+
world.setStateValue(key, occurrence);
|
|
357
|
+
const ctx = { world, it: itOf(target), occurrence };
|
|
358
|
+
data.chordOccurrence = occurrence;
|
|
359
|
+
data.chordDecisions = runtime.snapshotDecisions(clause.body, ctx);
|
|
360
|
+
return null;
|
|
361
|
+
},
|
|
362
|
+
postExecute(target, world, _actorId, data) {
|
|
363
|
+
runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'mutations');
|
|
364
|
+
},
|
|
365
|
+
postReport(target, world, _actorId, data) {
|
|
366
|
+
const reports = runtime.execStatements(clause.body, runtime.restoreCtx(world, itOf(target), data), 'reports');
|
|
367
|
+
const result = {};
|
|
368
|
+
const emit = [];
|
|
369
|
+
for (const event of reports) {
|
|
370
|
+
const payload = (event.data ?? {});
|
|
371
|
+
if (event.type === 'chord.phrase' && !result.override) {
|
|
372
|
+
result.override = { messageId: String(payload.messageId), params: payload.params ?? {} };
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
emit.push({ type: event.type, payload });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (emit.length)
|
|
379
|
+
result.emit = emit;
|
|
380
|
+
return result;
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* `define action` → a four-phase dispatch action (structurally typed —
|
|
386
|
+
* `Story.getCustomActions()` is untyped by design): the refusal ladder
|
|
387
|
+
* runs in validate, the matched CapabilityBehavior carries the phases,
|
|
388
|
+
* `otherwise refuse` is the dispatch-miss, and `when <player> <verbs>`
|
|
389
|
+
* rules fire in report.
|
|
390
|
+
*/
|
|
391
|
+
buildDispatchActions() {
|
|
392
|
+
return this.ir.actions.map((def) => this.buildDispatchAction(def));
|
|
393
|
+
}
|
|
394
|
+
buildDispatchAction(def) {
|
|
395
|
+
const runtime = this;
|
|
396
|
+
const actionId = `chord.action.${def.name}`;
|
|
397
|
+
const primarySlot = def.patterns.flatMap((p) => p.parts).find((part) => part.kind === 'slot')?.word;
|
|
398
|
+
return {
|
|
399
|
+
id: actionId,
|
|
400
|
+
group: 'interaction',
|
|
401
|
+
validate(context) {
|
|
402
|
+
const entity = context.command.directObject?.entity;
|
|
403
|
+
for (const refusal of def.refusals) {
|
|
404
|
+
if (refusal.kind === 'without' && !entity) {
|
|
405
|
+
return { valid: false, error: refusal.phraseKey };
|
|
406
|
+
}
|
|
407
|
+
if (refusal.kind === 'when' && entity) {
|
|
408
|
+
const ctx = {
|
|
409
|
+
world: context.world,
|
|
410
|
+
slots: runtime.slotBindings(primarySlot, entity, context.player),
|
|
411
|
+
};
|
|
412
|
+
if (runtime.evaluator.evalCondition(refusal.condition, ctx)) {
|
|
413
|
+
return { valid: false, error: refusal.phraseKey };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!entity)
|
|
418
|
+
return { valid: false, error: def.otherwise ?? 'cant' };
|
|
419
|
+
const slots = runtime.slotBindings(primarySlot, entity, context.player);
|
|
420
|
+
// Action-level requirements (`<subject> must …: <key>`, D6) run
|
|
421
|
+
// after the refusal ladder, before dispatch — the action's own
|
|
422
|
+
// gate, evaluated in the slots context (wired with the each
|
|
423
|
+
// package's zoo-chain fixes, 2026-07-12).
|
|
424
|
+
for (const must of def.musts) {
|
|
425
|
+
if (!runtime.evaluator.evalCondition(must.condition, { world: context.world, slots })) {
|
|
426
|
+
return { valid: false, error: must.phraseKey };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Dispatch: the first trait on the target with a behavior bound for
|
|
430
|
+
// this action claims it (per-world binding map, ADR-090/207).
|
|
431
|
+
// Instance-type lookup: ChordDataTrait types are per-instance, so
|
|
432
|
+
// the constructor-static path (getBehaviorForCapability) can't see
|
|
433
|
+
// them.
|
|
434
|
+
let behavior;
|
|
435
|
+
for (const trait of entity.traits.values()) {
|
|
436
|
+
behavior = context.world.getBehaviorBinding(trait.type, actionId)?.behavior;
|
|
437
|
+
if (behavior)
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
// A behavior host is optional when the action carries its own body
|
|
441
|
+
// (§5.4: the body IS the action's semantics — photographing has no
|
|
442
|
+
// per-trait behavior by design). No behavior AND no body = the
|
|
443
|
+
// dispatch miss, exactly as before.
|
|
444
|
+
if (!behavior && def.body.length === 0)
|
|
445
|
+
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
|
+
if (def.body.length) {
|
|
453
|
+
// The body's own validate partition (leading refusals/musts) and
|
|
454
|
+
// the pre-mutation decision snapshot (§5.4).
|
|
455
|
+
const bodyCtx = { world: context.world, slots };
|
|
456
|
+
const refusal = runtime.findRefusal(def.body, bodyCtx);
|
|
457
|
+
if (refusal)
|
|
458
|
+
return { valid: false, error: refusal };
|
|
459
|
+
context.sharedData.chordBodyDecisions = runtime.snapshotDecisions(def.body, bodyCtx);
|
|
460
|
+
}
|
|
461
|
+
context.sharedData.capEntity = entity;
|
|
462
|
+
context.sharedData.capBehavior = behavior;
|
|
463
|
+
context.sharedData.capShared = capShared;
|
|
464
|
+
return { valid: true };
|
|
465
|
+
},
|
|
466
|
+
execute(context) {
|
|
467
|
+
const entity = context.sharedData.capEntity;
|
|
468
|
+
const behavior = context.sharedData.capBehavior;
|
|
469
|
+
if (entity && behavior) {
|
|
470
|
+
behavior.execute(entity, context.world, context.player.id, context.sharedData.capShared);
|
|
471
|
+
}
|
|
472
|
+
if (entity && def.body.length) {
|
|
473
|
+
runtime.execStatements(def.body, runtime.actionBodyCtx(primarySlot, entity, context), 'mutations');
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
report(context) {
|
|
477
|
+
const entity = context.sharedData.capEntity;
|
|
478
|
+
const behavior = context.sharedData.capBehavior;
|
|
479
|
+
if (!entity)
|
|
480
|
+
return [];
|
|
481
|
+
const events = [];
|
|
482
|
+
if (behavior) {
|
|
483
|
+
const effects = behavior.report(entity, context.world, context.player.id, context.sharedData.capShared);
|
|
484
|
+
events.push(...effects.map((e) => context.event(e.type, e.payload)));
|
|
485
|
+
}
|
|
486
|
+
if (def.body.length) {
|
|
487
|
+
events.push(...runtime.execStatements(def.body, runtime.actionBodyCtx(primarySlot, entity, context), 'reports'));
|
|
488
|
+
}
|
|
489
|
+
events.push(...runtime.fireAfterClauses(def.name, entity, context.world));
|
|
490
|
+
return events;
|
|
491
|
+
},
|
|
492
|
+
blocked(context, result) {
|
|
493
|
+
const key = result.error ?? def.otherwise ?? 'cant';
|
|
494
|
+
const event = runtime.phraseEvent(key, { world: context.world });
|
|
495
|
+
return [context.event(event.type, (event.data ?? {}))];
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
slotBindings(primarySlot, entity, player) {
|
|
500
|
+
const slots = { actor: player.id };
|
|
501
|
+
if (primarySlot)
|
|
502
|
+
slots[primarySlot] = entity.id;
|
|
503
|
+
return slots;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Execution context for a `define action` body (§5.4): grammar slots
|
|
507
|
+
* bound, no `it` (action bodies have no owner), decision snapshot from
|
|
508
|
+
* validate carried through sharedData.
|
|
509
|
+
*/
|
|
510
|
+
actionBodyCtx(primarySlot, entity, context) {
|
|
511
|
+
return {
|
|
512
|
+
world: context.world,
|
|
513
|
+
slots: this.slotBindings(primarySlot, entity, context.player),
|
|
514
|
+
decisions: context.sharedData.chordBodyDecisions,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// -------------------------------------------- scheduler constructs (Phase B)
|
|
518
|
+
/**
|
|
519
|
+
* Build the story's scheduler daemons (`once` / `every N turns` /
|
|
520
|
+
* `define sequence` / every-turn trait clauses). ALL progression state is
|
|
521
|
+
* namespaced world state — save/restore/undo cover it with no
|
|
522
|
+
* getRunnerState plumbing (design.md §6). Registered by
|
|
523
|
+
* ChordStory.onEngineReady; exposed for direct unit driving.
|
|
524
|
+
*/
|
|
525
|
+
buildSchedulerDaemons() {
|
|
526
|
+
const daemons = [];
|
|
527
|
+
for (const sequence of this.ir.sequences) {
|
|
528
|
+
// Steps arm in order: `at turn N` on the wall clock, `N turns later`
|
|
529
|
+
// relative to the PREVIOUS step's firing turn, `when <owner> becomes
|
|
530
|
+
// <state>` on a state anchor (ratchet D10). Pointer and last-fired
|
|
531
|
+
// turn live in world state — save/restore covers progression.
|
|
532
|
+
const slug = sequence.name.replace(/\s+/g, '-');
|
|
533
|
+
const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}sequence.${slug}`;
|
|
534
|
+
const firedKey = `${key}.turn`;
|
|
535
|
+
const stepReady = (step, world, turn) => {
|
|
536
|
+
switch (step.timing) {
|
|
537
|
+
case 'at-turn':
|
|
538
|
+
return turn >= step.turns;
|
|
539
|
+
case 'later': {
|
|
540
|
+
const lastFired = world.getStateValue(firedKey) ?? 0;
|
|
541
|
+
return turn >= lastFired + step.turns;
|
|
542
|
+
}
|
|
543
|
+
case 'becomes': {
|
|
544
|
+
if (!step.anchor)
|
|
545
|
+
return false;
|
|
546
|
+
if (step.anchor.owner === 'story') {
|
|
547
|
+
return world.getStateValue(state_keys_1.CHORD_STORY_STATE_KEY) === step.anchor.state;
|
|
548
|
+
}
|
|
549
|
+
return world.getStateValue(state_keys_1.CHORD_STATE_PREFIX + step.anchor.owner) === step.anchor.state;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
daemons.push({
|
|
554
|
+
id: `chord.sequence.${slug}`,
|
|
555
|
+
name: `sequence ${sequence.name}`,
|
|
556
|
+
condition: (ctx) => {
|
|
557
|
+
const next = ctx.world.getStateValue(key) ?? 0;
|
|
558
|
+
return next < sequence.steps.length && stepReady(sequence.steps[next], ctx.world, ctx.turn);
|
|
559
|
+
},
|
|
560
|
+
run: (ctx) => {
|
|
561
|
+
const next = ctx.world.getStateValue(key) ?? 0;
|
|
562
|
+
ctx.world.setStateValue(key, next + 1);
|
|
563
|
+
ctx.world.setStateValue(firedKey, ctx.turn);
|
|
564
|
+
const step = sequence.steps[next];
|
|
565
|
+
return this.narrated(this.execStatements(step.body, { world: ctx.world, occurrence: next + 1 }));
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
// Entity every-turn clauses (`on every turn while …[, once]` in a
|
|
570
|
+
// create block): one daemon per clause, `it` = the owning entity
|
|
571
|
+
// (stickiness — the ownership package's replacement for floating
|
|
572
|
+
// `once <cond>` rules).
|
|
573
|
+
this.ir.entities.forEach((irEntity) => {
|
|
574
|
+
irEntity.onClauses.forEach((clause, clauseIndex) => {
|
|
575
|
+
if (clause.binding !== 'every-turn')
|
|
576
|
+
return;
|
|
577
|
+
const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}entity-turn.${irEntity.id}.${clauseIndex}`;
|
|
578
|
+
daemons.push({
|
|
579
|
+
id: `chord.entity-turn.${irEntity.id}.${clauseIndex}`,
|
|
580
|
+
name: `on every turn (${irEntity.id})`,
|
|
581
|
+
run: (ctx) => {
|
|
582
|
+
// Presence gate (decision 10): performances need an audience —
|
|
583
|
+
// the clause does not FIRE off-stage. Checked before the
|
|
584
|
+
// condition so an off-stage `one chance in N` never draws the
|
|
585
|
+
// RNG (AC-5 determinism for on-stage firings) and `, once` is
|
|
586
|
+
// never consumed unwitnessed. Presence, not sight.
|
|
587
|
+
if (!this.playerPresentAt(ctx.world, irEntity.id))
|
|
588
|
+
return [];
|
|
589
|
+
const evalCtx = { world: ctx.world, it: irEntity.id };
|
|
590
|
+
if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
|
|
591
|
+
return [];
|
|
592
|
+
const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
|
|
593
|
+
if (clause.once && fired > 1)
|
|
594
|
+
return []; // `, once` (D5)
|
|
595
|
+
ctx.world.setStateValue(key, fired);
|
|
596
|
+
evalCtx.occurrence = fired;
|
|
597
|
+
return this.narrated(this.execStatements(clause.body, evalCtx));
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
// Every-turn trait clauses (`on every turn while …[, once]`): one
|
|
603
|
+
// daemon per clause, evaluated per entity carrying the trait. The
|
|
604
|
+
// composition condition (`chatty while not after-hours`) gates per
|
|
605
|
+
// entity per turn (Prerequisite 2's NPC-behavior shape).
|
|
606
|
+
this.ir.traits.forEach((trait) => {
|
|
607
|
+
trait.onClauses.forEach((clause, clauseIndex) => {
|
|
608
|
+
if (clause.binding !== 'every-turn')
|
|
609
|
+
return;
|
|
610
|
+
const traitType = state_keys_1.CHORD_TRAIT_PREFIX + trait.name;
|
|
611
|
+
daemons.push({
|
|
612
|
+
id: `chord.trait-turn.${trait.name}.${clauseIndex}`,
|
|
613
|
+
name: `on every turn (${trait.name})`,
|
|
614
|
+
run: (ctx) => {
|
|
615
|
+
const out = [];
|
|
616
|
+
for (const irEntity of this.ir.entities) {
|
|
617
|
+
const comp = irEntity.traits.find((t) => t.name === trait.name);
|
|
618
|
+
if (!comp)
|
|
619
|
+
continue;
|
|
620
|
+
const worldId = this.host.entityId(irEntity.id);
|
|
621
|
+
const entity = worldId ? ctx.world.getEntity(worldId) : undefined;
|
|
622
|
+
if (!entity?.has(traitType))
|
|
623
|
+
continue;
|
|
624
|
+
// Presence gate (decision 10) — before any condition so the
|
|
625
|
+
// RNG stream and `, once` are untouched off-stage.
|
|
626
|
+
if (!this.playerPresentAt(ctx.world, irEntity.id))
|
|
627
|
+
continue;
|
|
628
|
+
const evalCtx = { world: ctx.world, it: irEntity.id };
|
|
629
|
+
if (comp.condition && !this.evaluator.evalCondition(comp.condition, evalCtx))
|
|
630
|
+
continue;
|
|
631
|
+
if (clause.condition && !this.evaluator.evalCondition(clause.condition, evalCtx))
|
|
632
|
+
continue;
|
|
633
|
+
const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}trait-turn.${trait.name}.${clauseIndex}.${irEntity.id}`;
|
|
634
|
+
const fired = (ctx.world.getStateValue(key) ?? 0) + 1;
|
|
635
|
+
if (clause.once && fired > 1)
|
|
636
|
+
continue; // `, once` (D5)
|
|
637
|
+
ctx.world.setStateValue(key, fired);
|
|
638
|
+
evalCtx.occurrence = fired;
|
|
639
|
+
out.push(...this.execStatements(clause.body, evalCtx));
|
|
640
|
+
}
|
|
641
|
+
return this.narrated(out);
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
// Z3: a `disappeared` narration enqueued OUTSIDE statement execution
|
|
647
|
+
// (a TS-initiated removeEntity — daemon, hatch, interceptor) has no
|
|
648
|
+
// report pass to drain it; this daemon delivers it on the tick.
|
|
649
|
+
// Registered only when the channel is authored, so channel-less
|
|
650
|
+
// stories keep their exact daemon roster.
|
|
651
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
652
|
+
if (Object.keys(table).some((key) => key.endsWith('.disappeared'))) {
|
|
653
|
+
daemons.push({
|
|
654
|
+
id: 'chord.channel-drain',
|
|
655
|
+
name: 'Z3 channel narration drain',
|
|
656
|
+
condition: () => this.pendingChannelEvents.length > 0,
|
|
657
|
+
run: () => this.drainChannelEvents(),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return daemons;
|
|
661
|
+
}
|
|
662
|
+
/** Scheduler-returned events must narrate to reach the transcript. */
|
|
663
|
+
narrated(events) {
|
|
664
|
+
return events.map((e) => ({ ...e, narrate: true }));
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Decision 10 presence semantics: the player shares the owner's location.
|
|
668
|
+
* A room owner means the player is IN that room; for anything else the
|
|
669
|
+
* two share a containing room (same co-location rule as can-see/can-reach
|
|
670
|
+
* — presence, not sight, so the snake speaks in darkness).
|
|
671
|
+
*/
|
|
672
|
+
playerPresentAt(world, irEntityId) {
|
|
673
|
+
const ownerId = this.host.entityId(irEntityId);
|
|
674
|
+
const playerId = world.getPlayer()?.id;
|
|
675
|
+
if (!ownerId || !playerId)
|
|
676
|
+
return false;
|
|
677
|
+
if (ownerId === playerId)
|
|
678
|
+
return true;
|
|
679
|
+
const playerRoom = world.getContainingRoom(playerId)?.id ?? world.getLocation(playerId);
|
|
680
|
+
const owner = world.getEntity(ownerId);
|
|
681
|
+
if (owner?.has(world_model_1.TraitType.ROOM))
|
|
682
|
+
return playerRoom === ownerId;
|
|
683
|
+
const ownerRoom = world.getContainingRoom(ownerId)?.id ?? world.getLocation(ownerId);
|
|
684
|
+
return ownerRoom !== undefined && ownerRoom === playerRoom;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Fire the target entity's `after <verb> it` clauses when a dispatch
|
|
688
|
+
* action completes — the loader-internal mechanism the Phase 1 spike
|
|
689
|
+
* confirmed (interceptor hooks never fire on the dispatch path; the
|
|
690
|
+
* runtime owns these actions, so reactions run in their report phase).
|
|
691
|
+
*/
|
|
692
|
+
fireAfterClauses(actionName, target, world) {
|
|
693
|
+
const out = [];
|
|
694
|
+
const targetIrId = this.host.irIdOf(target.id);
|
|
695
|
+
if (targetIrId === undefined)
|
|
696
|
+
return out;
|
|
697
|
+
const irEntity = this.ir.entities.find((e) => e.id === targetIrId);
|
|
698
|
+
if (!irEntity)
|
|
699
|
+
return out;
|
|
700
|
+
irEntity.onClauses.forEach((clause, clauseIndex) => {
|
|
701
|
+
if (clause.clauseKind !== 'after' || clause.action !== actionName)
|
|
702
|
+
return;
|
|
703
|
+
const ctx = { world, it: targetIrId };
|
|
704
|
+
if (clause.condition && !this.evaluator.evalCondition(clause.condition, ctx))
|
|
705
|
+
return;
|
|
706
|
+
const key = `${state_keys_1.CHORD_OCCURRENCE_PREFIX}after.${irEntity.id}.${actionName}.${clauseIndex}`;
|
|
707
|
+
const occurrence = (world.getStateValue(key) ?? 0) + 1;
|
|
708
|
+
if (clause.once && occurrence > 1)
|
|
709
|
+
return; // `, once` (D5)
|
|
710
|
+
world.setStateValue(key, occurrence);
|
|
711
|
+
ctx.occurrence = occurrence;
|
|
712
|
+
ctx.decisions = this.snapshotDecisions(clause.body, ctx);
|
|
713
|
+
out.push(...this.execStatements(clause.body, ctx));
|
|
714
|
+
});
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
// ------------------------------------------------------ derived (dark)
|
|
718
|
+
derivedDarkRooms() {
|
|
719
|
+
const out = [];
|
|
720
|
+
for (const entity of this.ir.entities) {
|
|
721
|
+
for (const trait of entity.traits) {
|
|
722
|
+
if (trait.name === 'dark' && trait.condition)
|
|
723
|
+
out.push({ entity, condition: trait.condition });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return out;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Re-evaluate derived properties: `dark while` into `RoomTrait.isDark`,
|
|
730
|
+
* and `is blocked while <cond>` into blockedExits (block/unblock per the
|
|
731
|
+
* condition's current truth — grammar log 2026-07-10).
|
|
732
|
+
*/
|
|
733
|
+
recomputeDerived(world) {
|
|
734
|
+
for (const { entity, condition } of this.derivedDarkRooms()) {
|
|
735
|
+
const worldId = this.host.entityId(entity.id);
|
|
736
|
+
const room = worldId ? world.getEntity(worldId)?.get(world_model_1.TraitType.ROOM) : undefined;
|
|
737
|
+
if (!room)
|
|
738
|
+
continue;
|
|
739
|
+
room.isDark = this.evaluator.evalCondition(condition, { world });
|
|
740
|
+
}
|
|
741
|
+
for (const irEntity of this.ir.entities) {
|
|
742
|
+
for (const blocked of irEntity.blockedExits) {
|
|
743
|
+
if (!blocked.condition)
|
|
744
|
+
continue;
|
|
745
|
+
const worldId = this.host.entityId(irEntity.id);
|
|
746
|
+
const room = worldId ? world.getEntity(worldId) : undefined;
|
|
747
|
+
if (!room)
|
|
748
|
+
continue;
|
|
749
|
+
const direction = world_model_1.Direction[blocked.direction.toUpperCase()];
|
|
750
|
+
if (!direction)
|
|
751
|
+
continue;
|
|
752
|
+
const holds = this.evaluator.evalCondition(blocked.condition, { world, it: irEntity.id });
|
|
753
|
+
if (holds) {
|
|
754
|
+
world_model_1.RoomBehavior.blockExit(room, direction, this.blockedPhraseText(blocked.phraseKey));
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
world_model_1.RoomBehavior.unblockExit(room, direction);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
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
|
+
// ------------------------------------------------------------ statements
|
|
772
|
+
/**
|
|
773
|
+
* Execute a statement tree. `phase` narrows which leaves act:
|
|
774
|
+
* 'mutations' applies change/set/move only; 'reports' collects
|
|
775
|
+
* phrase/emit/win/lose only; 'all' (rules) does both in source order.
|
|
776
|
+
*/
|
|
777
|
+
execStatements(body, ctx, phase = 'all') {
|
|
778
|
+
const events = [];
|
|
779
|
+
// Statement `when` suffix (ratchet D7): the statement acts only if the
|
|
780
|
+
// condition holds at execution. Evaluated per phase-pass over the same
|
|
781
|
+
// snapshot world — mutations and reports agree because the suffix runs
|
|
782
|
+
// before either phase's own mutations of this statement.
|
|
783
|
+
const whenHolds = (stmt) => !stmt.stmtWhen || this.evaluator.evalCondition(stmt.stmtWhen, ctx);
|
|
784
|
+
for (const stmt of body) {
|
|
785
|
+
switch (stmt.kind) {
|
|
786
|
+
case 'phrase':
|
|
787
|
+
if (phase !== 'mutations' && whenHolds(stmt))
|
|
788
|
+
events.push(this.phraseEvent(stmt.phraseKey, ctx, stmt.params));
|
|
789
|
+
break;
|
|
790
|
+
case 'emit':
|
|
791
|
+
if (phase !== 'mutations' && whenHolds(stmt))
|
|
792
|
+
events.push(this.rawEvent(stmt.event, {}));
|
|
793
|
+
break;
|
|
794
|
+
case 'win':
|
|
795
|
+
case 'lose':
|
|
796
|
+
if (phase !== 'mutations' && whenHolds(stmt)) {
|
|
797
|
+
if (stmt.phraseKey)
|
|
798
|
+
events.push(this.phraseEvent(stmt.phraseKey, ctx));
|
|
799
|
+
events.push(this.host.triggerEnding(ctx.world, stmt.kind === 'win' ? 'victory' : 'defeat', stmt.phraseKey ?? undefined));
|
|
800
|
+
}
|
|
801
|
+
break;
|
|
802
|
+
case 'change': {
|
|
803
|
+
if (phase !== 'reports' && whenHolds(stmt)) {
|
|
804
|
+
if (stmt.entity.kind === 'story') {
|
|
805
|
+
// `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(state_keys_1.CHORD_STORY_STATE_KEY), stmt.state, 'the story', stmt.span);
|
|
807
|
+
ctx.world.setStateValue(state_keys_1.CHORD_STORY_STATE_KEY, stmt.state);
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
const irId = this.irIdOfValue(stmt.entity, ctx);
|
|
811
|
+
const set = this.stateSetOf(irId, stmt.state);
|
|
812
|
+
if (set) {
|
|
813
|
+
this.checkForwardMarch(set.states, set.reversible, ctx.world.getStateValue(state_keys_1.CHORD_STATE_PREFIX + irId), stmt.state, irId, stmt.span);
|
|
814
|
+
}
|
|
815
|
+
ctx.world.setStateValue(state_keys_1.CHORD_STATE_PREFIX + irId, stmt.state);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
case 'move': {
|
|
821
|
+
if (phase !== 'reports' && whenHolds(stmt)) {
|
|
822
|
+
const thing = this.evaluator.entityValue(stmt.entity, ctx);
|
|
823
|
+
const place = this.evaluator.entityValue(stmt.place, ctx);
|
|
824
|
+
this.moveWithLifecycle(thing, place, ctx);
|
|
825
|
+
}
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
case 'remove': {
|
|
829
|
+
if (phase !== 'reports' && whenHolds(stmt)) {
|
|
830
|
+
const thing = this.evaluator.entityValue(stmt.entity, ctx);
|
|
831
|
+
// Z6 (ADR-213): the loader's pre-removal observer fires inside
|
|
832
|
+
// removeEntity and enqueues any witnessed `disappeared`
|
|
833
|
+
// narration; the report-collecting pass drains it. Never
|
|
834
|
+
// rendered inline from the mutation pass.
|
|
835
|
+
ctx.world.removeEntity(thing);
|
|
836
|
+
}
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
case 'set': {
|
|
840
|
+
if (phase !== 'reports') {
|
|
841
|
+
const value = this.evaluator.evalValue(stmt.value, ctx);
|
|
842
|
+
if (stmt.target.kind === 'field') {
|
|
843
|
+
// Trait data fields (`set its treats to 3`) write the entity's
|
|
844
|
+
// chord trait instance — world state via traits (AC-6-safe).
|
|
845
|
+
const baseId = this.evaluator.entityValue(stmt.target.base, ctx);
|
|
846
|
+
this.writeChordTraitField(ctx.world, baseId, stmt.target.field, value, stmt.span);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
throw new errors_1.LoadError('`set` targets a trait data field.', stmt.span);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
case 'award': {
|
|
855
|
+
if (phase !== 'reports' && whenHolds(stmt)) {
|
|
856
|
+
// `award <score>` — dedup by identity (ADR-129), so repeat
|
|
857
|
+
// awards are no-ops and `, once` is automatic. Names arrive
|
|
858
|
+
// owner-qualified from the analyzer (ratchet D12).
|
|
859
|
+
if (stmt.expression.length !== 1) {
|
|
860
|
+
throw new errors_1.LoadError('Only `award <score-name>` is supported (expression awards are later scope).', stmt.span);
|
|
861
|
+
}
|
|
862
|
+
const name = stmt.expression[0];
|
|
863
|
+
const worth = this.scoreWorth.get(name);
|
|
864
|
+
if (worth === undefined) {
|
|
865
|
+
throw new errors_1.LoadError(`\`${name}\` is not a declared score.`, stmt.span);
|
|
866
|
+
}
|
|
867
|
+
ctx.world.awardScore(name, worth, name);
|
|
868
|
+
}
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
case 'refuse':
|
|
872
|
+
case 'must':
|
|
873
|
+
case 'refuse-when':
|
|
874
|
+
// The refusal partition is consumed by findRefusal (validate
|
|
875
|
+
// phase); nothing to do in execute/report passes.
|
|
876
|
+
break;
|
|
877
|
+
case 'select-on': {
|
|
878
|
+
const decided = ctx.decisions?.get(stmt) ?? this.decideSelectOn(stmt, ctx);
|
|
879
|
+
const arm = stmt.arms.find((a) => a.value === decided);
|
|
880
|
+
if (arm)
|
|
881
|
+
events.push(...this.execStatements(arm.body, ctx, phase));
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
case 'select-strategy': {
|
|
885
|
+
const index = this.decideStrategy(stmt, ctx);
|
|
886
|
+
const alternative = stmt.alternatives[index];
|
|
887
|
+
if (alternative)
|
|
888
|
+
events.push(...this.execStatements(alternative, ctx, phase));
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
case 'ordinal':
|
|
892
|
+
if (ctx.occurrence === stmt.ordinal) {
|
|
893
|
+
events.push(...this.execStatements(stmt.body, ctx, phase));
|
|
894
|
+
}
|
|
895
|
+
break;
|
|
896
|
+
case 'each':
|
|
897
|
+
// E3 (ratchet 2026-07-12): run the body once per matching entity
|
|
898
|
+
// in creation order, `the match` bound to it; `it` and every
|
|
899
|
+
// other binding pass through untouched. Empty set = no-op.
|
|
900
|
+
for (const irId of this.eachMatches(stmt, ctx)) {
|
|
901
|
+
events.push(...this.execStatements(stmt.body, { ...ctx, match: irId }, phase));
|
|
902
|
+
}
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
// Z3: witnessed lifecycle narration enqueued during mutation phases
|
|
907
|
+
// (move/remove above; the removal observer) lands in the next report-
|
|
908
|
+
// collecting pass. Mutations-only passes never drain — their return
|
|
909
|
+
// value is discarded by the interceptor call sites.
|
|
910
|
+
if (phase !== 'mutations')
|
|
911
|
+
events.push(...this.drainChannelEvents());
|
|
912
|
+
return events;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Z3: `move` with witnessed-only lifecycle narration (D11). `exited`
|
|
916
|
+
* fires when the player shares the mover's SOURCE room at the transition
|
|
917
|
+
* (and the move really changes rooms); `entered` when the player shares
|
|
918
|
+
* the DESTINATION room after arrival. Unwitnessed transitions narrate
|
|
919
|
+
* nothing and consume nothing; narration is enqueued, never emitted
|
|
920
|
+
* inline from the mutation pass. Moving the player itself never narrates.
|
|
921
|
+
*
|
|
922
|
+
* @param thingWorldId the moved entity's world id
|
|
923
|
+
* @param placeWorldId the destination's world id
|
|
924
|
+
* @param ctx the executing context (live world)
|
|
925
|
+
*/
|
|
926
|
+
moveWithLifecycle(thingWorldId, placeWorldId, ctx) {
|
|
927
|
+
const world = ctx.world;
|
|
928
|
+
const roomOf = (id) => world.getContainingRoom(id)?.id ?? world.getLocation(id);
|
|
929
|
+
const fromRoom = roomOf(thingWorldId);
|
|
930
|
+
world.moveEntity(thingWorldId, placeWorldId);
|
|
931
|
+
const playerId = world.getPlayer()?.id;
|
|
932
|
+
if (!playerId || thingWorldId === playerId)
|
|
933
|
+
return;
|
|
934
|
+
const irId = this.host.irIdOf(thingWorldId);
|
|
935
|
+
if (!irId)
|
|
936
|
+
return;
|
|
937
|
+
const toRoom = roomOf(thingWorldId);
|
|
938
|
+
if (fromRoom === toRoom)
|
|
939
|
+
return; // not a room transition — nothing to witness
|
|
940
|
+
const playerRoom = roomOf(playerId);
|
|
941
|
+
if (playerRoom === undefined)
|
|
942
|
+
return;
|
|
943
|
+
if (playerRoom === fromRoom) {
|
|
944
|
+
const event = this.channelEvent(irId, 'exited', world);
|
|
945
|
+
if (event)
|
|
946
|
+
this.enqueueChannelEvent(event);
|
|
947
|
+
}
|
|
948
|
+
if (playerRoom === toRoom) {
|
|
949
|
+
const event = this.channelEvent(irId, 'entered', world);
|
|
950
|
+
if (event)
|
|
951
|
+
this.enqueueChannelEvent(event);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* The declared set a `change` target state belongs to on an entity, with
|
|
956
|
+
* its D4 policy — a composed trait's set, or the entity's own `states:`
|
|
957
|
+
* line (merged list minus trait states). Null when the state is unknown
|
|
958
|
+
* (the analyzer gates that; being lenient here keeps the check pure).
|
|
959
|
+
*/
|
|
960
|
+
stateSetOf(irId, state) {
|
|
961
|
+
const irEntity = this.ir.entities.find((e) => e.id === irId);
|
|
962
|
+
if (!irEntity)
|
|
963
|
+
return null;
|
|
964
|
+
const traitStates = new Set();
|
|
965
|
+
for (const comp of irEntity.traits) {
|
|
966
|
+
const trait = this.ir.traits.find((t) => t.name === comp.name);
|
|
967
|
+
if (!trait)
|
|
968
|
+
continue;
|
|
969
|
+
if (trait.states.includes(state)) {
|
|
970
|
+
return { states: trait.states, reversible: trait.statesReversible };
|
|
971
|
+
}
|
|
972
|
+
for (const s of trait.states)
|
|
973
|
+
traitStates.add(s);
|
|
974
|
+
}
|
|
975
|
+
const own = irEntity.states.filter((s) => !traitStates.has(s));
|
|
976
|
+
return own.includes(state) ? { states: own, reversible: irEntity.statesReversible } : null;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* D4 forward-march, runtime half: within a non-reversible set, `change`
|
|
980
|
+
* may only move forward in declaration order. (The analyzer catches the
|
|
981
|
+
* statically provable case — change-to-initial; this catches the rest
|
|
982
|
+
* with the live current state.) Cross-set transitions and same-state
|
|
983
|
+
* no-ops pass.
|
|
984
|
+
*/
|
|
985
|
+
checkForwardMarch(states, reversible, current, target, ownerDesc, span) {
|
|
986
|
+
if (reversible || typeof current !== 'string')
|
|
987
|
+
return;
|
|
988
|
+
const from = states.indexOf(current);
|
|
989
|
+
const to = states.indexOf(target);
|
|
990
|
+
if (from >= 0 && to >= 0 && to < from) {
|
|
991
|
+
throw new errors_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
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Leading-refusal scan (§5.4 validate partition): unconditional `refuse`,
|
|
996
|
+
* `must` requirements (refuse when the requirement FAILS, ratchet D6),
|
|
997
|
+
* and `refuse when` prohibitions (refuse when the hazard HOLDS) — checked
|
|
998
|
+
* in source order until the first non-refusal statement.
|
|
999
|
+
*/
|
|
1000
|
+
findRefusal(body, ctx) {
|
|
1001
|
+
for (const stmt of body) {
|
|
1002
|
+
if (stmt.kind === 'refuse')
|
|
1003
|
+
return stmt.phraseKey;
|
|
1004
|
+
if (stmt.kind === 'must') {
|
|
1005
|
+
if (!this.evaluator.evalCondition(stmt.condition, ctx))
|
|
1006
|
+
return stmt.phraseKey;
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (stmt.kind === 'refuse-when') {
|
|
1010
|
+
if (this.evaluator.evalCondition(stmt.condition, ctx))
|
|
1011
|
+
return stmt.phraseKey;
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
break; // first non-refusal statement ends the validate partition
|
|
1015
|
+
}
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Snapshot every branching decision (`if` and `select-on`) before
|
|
1020
|
+
* mutations run, walking only the branches actually taken (§5.4: the
|
|
1021
|
+
* report phase must see the same routing the execute phase did).
|
|
1022
|
+
*/
|
|
1023
|
+
snapshotDecisions(body, ctx) {
|
|
1024
|
+
const decisions = new Map();
|
|
1025
|
+
const walk = (stmts) => {
|
|
1026
|
+
for (const stmt of stmts) {
|
|
1027
|
+
switch (stmt.kind) {
|
|
1028
|
+
case 'select-on': {
|
|
1029
|
+
const decided = this.decideSelectOn(stmt, ctx);
|
|
1030
|
+
decisions.set(stmt, decided);
|
|
1031
|
+
const arm = stmt.arms.find((a) => a.value === decided);
|
|
1032
|
+
if (arm)
|
|
1033
|
+
walk(arm.body);
|
|
1034
|
+
break;
|
|
1035
|
+
}
|
|
1036
|
+
case 'select-strategy':
|
|
1037
|
+
stmt.alternatives.forEach((a) => walk(a));
|
|
1038
|
+
break;
|
|
1039
|
+
case 'ordinal':
|
|
1040
|
+
if (ctx.occurrence === stmt.ordinal)
|
|
1041
|
+
walk(stmt.body);
|
|
1042
|
+
break;
|
|
1043
|
+
case 'each':
|
|
1044
|
+
// Pin the match set pre-mutation (iteration is routing, same
|
|
1045
|
+
// as a select arm): joined IR ids, '' for the empty set. The
|
|
1046
|
+
// body is NOT walked — a select-on inside an `each` body may
|
|
1047
|
+
// decide differently per match, and this map is keyed by
|
|
1048
|
+
// statement identity alone, so those decide live per pass
|
|
1049
|
+
// (recorded follow-up; no shipped construct hits it).
|
|
1050
|
+
decisions.set(stmt, this.evaluator.matchesOf(stmt.condition, ctx).join('|'));
|
|
1051
|
+
break;
|
|
1052
|
+
default:
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
walk(body);
|
|
1058
|
+
return decisions;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* The match set for an `each` block: the pre-mutation snapshot when one
|
|
1062
|
+
* exists (§5.4 — the report pass must visit the same entities the
|
|
1063
|
+
* execute pass did, even after the body's own mutations change who
|
|
1064
|
+
* matches), else a live enumeration (single-pass contexts: event
|
|
1065
|
+
* clauses, sequences, daemons, and blocks nested inside another `each`).
|
|
1066
|
+
*/
|
|
1067
|
+
eachMatches(stmt, ctx) {
|
|
1068
|
+
const snapped = ctx.decisions?.get(stmt);
|
|
1069
|
+
if (snapped !== undefined)
|
|
1070
|
+
return snapped ? snapped.split('|') : [];
|
|
1071
|
+
return this.evaluator.matchesOf(stmt.condition, ctx);
|
|
1072
|
+
}
|
|
1073
|
+
decideSelectOn(stmt, ctx) {
|
|
1074
|
+
return String(this.evaluator.evalValue(stmt.subject, ctx));
|
|
1075
|
+
}
|
|
1076
|
+
decideStrategy(stmt, ctx) {
|
|
1077
|
+
const count = stmt.alternatives.length;
|
|
1078
|
+
if (count === 0)
|
|
1079
|
+
return 0;
|
|
1080
|
+
// Occurrence-ordered strategies key off world state; randomly keys off
|
|
1081
|
+
// the persisted chance stream (via one draw per firing). Sticky (Z5)
|
|
1082
|
+
// reuses the same slot with the Choice encoding instead of an
|
|
1083
|
+
// occurrence count: stored = chosen index + 1, 0/undefined = unchosen.
|
|
1084
|
+
const key = state_keys_1.CHORD_OCCURRENCE_PREFIX + `select.${stmt.span.line}`;
|
|
1085
|
+
if (stmt.strategy === 'sticky') {
|
|
1086
|
+
const stored = ctx.world.getStateValue(key);
|
|
1087
|
+
if (stored && stored > 0)
|
|
1088
|
+
return Math.min(stored - 1, count - 1);
|
|
1089
|
+
const i = this.randomIndex(count, ctx);
|
|
1090
|
+
ctx.world.setStateValue(key, i + 1);
|
|
1091
|
+
return i;
|
|
1092
|
+
}
|
|
1093
|
+
const n = ctx.world.getStateValue(key) ?? 0;
|
|
1094
|
+
ctx.world.setStateValue(key, n + 1);
|
|
1095
|
+
switch (stmt.strategy) {
|
|
1096
|
+
case 'cycling':
|
|
1097
|
+
return n % count;
|
|
1098
|
+
case 'stopping':
|
|
1099
|
+
return Math.min(n, count - 1);
|
|
1100
|
+
case 'first-time':
|
|
1101
|
+
return n === 0 ? 0 : Math.min(1, count - 1);
|
|
1102
|
+
case 'randomly':
|
|
1103
|
+
return this.randomIndex(count, ctx);
|
|
1104
|
+
default:
|
|
1105
|
+
throw new errors_1.LoadError(`Unknown select strategy \`${stmt.strategy}\`.`, stmt.span);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
randomIndex(count, ctx) {
|
|
1109
|
+
// Reuse the persisted chance stream: draw until a bucket resolves.
|
|
1110
|
+
for (let i = 0; i < count - 1; i++) {
|
|
1111
|
+
if (this.evaluator.evalCondition({ kind: 'chance', n: count - i }, ctx))
|
|
1112
|
+
return i;
|
|
1113
|
+
}
|
|
1114
|
+
return count - 1;
|
|
1115
|
+
}
|
|
1116
|
+
// --------------------------------------------------------------- phrases
|
|
1117
|
+
/** Z3 lifecycle narration awaiting the next report-collecting pass (never rendered inline — ADR-213 §2). */
|
|
1118
|
+
pendingChannelEvents = [];
|
|
1119
|
+
/** Enqueue witnessed channel narration (Z3) — it lands in the turn's report pass. */
|
|
1120
|
+
enqueueChannelEvent(event) {
|
|
1121
|
+
this.pendingChannelEvents.push(event);
|
|
1122
|
+
}
|
|
1123
|
+
/** Drain pending channel narration (Z3) — report-collecting passes and the drain daemon consume it. */
|
|
1124
|
+
drainChannelEvents() {
|
|
1125
|
+
return this.pendingChannelEvents.splice(0, this.pendingChannelEvents.length);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Z3: build the channel phrase event for an owner (`entered` / `exited` /
|
|
1129
|
+
* `disappeared`). The phrase is the owner's `<irId>.<channel>` block;
|
|
1130
|
+
* `Choice` counters key `(ownerWorldId, channel)` — ADR-212 §4's owner +
|
|
1131
|
+
* channel-key convention, shared with the `present` slot entries.
|
|
1132
|
+
*
|
|
1133
|
+
* @param ownerIrId the channel owner's IR entity id
|
|
1134
|
+
* @param channel the channel key (`entered`/`exited`/`disappeared`)
|
|
1135
|
+
* @param world the live world
|
|
1136
|
+
* @returns the phrase event, or null when the owner has no such block
|
|
1137
|
+
*/
|
|
1138
|
+
channelEvent(ownerIrId, channel, world) {
|
|
1139
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
1140
|
+
if (!table[`${ownerIrId}.${channel}`])
|
|
1141
|
+
return null;
|
|
1142
|
+
const ownerWorldId = this.host.entityId(ownerIrId);
|
|
1143
|
+
if (!ownerWorldId)
|
|
1144
|
+
return null;
|
|
1145
|
+
return this.phraseEvent(`${ownerIrId}.${channel}`, { world, it: ownerIrId }, undefined, {
|
|
1146
|
+
entityId: ownerWorldId,
|
|
1147
|
+
messageKey: channel,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Build the semantic event for `phrase <key>`: entity-scoped override
|
|
1152
|
+
* resolution (prereq 4), strategy variants as a persistent Choice atom,
|
|
1153
|
+
* and hatch producers bound by marker name.
|
|
1154
|
+
*
|
|
1155
|
+
* @param counter Z3 channel counter identity — overrides the default
|
|
1156
|
+
* `('chord', overrideKey)` Choice keying with `(owner, channelKey)`.
|
|
1157
|
+
*/
|
|
1158
|
+
phraseEvent(key, ctx, stmtParams, counter) {
|
|
1159
|
+
const table = this.ir.phrases.locales[this.ir.phrases.defaultLocale] ?? {};
|
|
1160
|
+
const overrideKey = ctx.it && table[`${ctx.it}.${key}`] ? `${ctx.it}.${key}` : key;
|
|
1161
|
+
const phrase = table[overrideKey];
|
|
1162
|
+
if (!phrase)
|
|
1163
|
+
throw new errors_1.LoadError(`Phrase \`${key}\` is missing from the IR at emit time.`);
|
|
1164
|
+
const params = {};
|
|
1165
|
+
// Authored `with <param> = <value>` bindings (zoo-chain follow-up,
|
|
1166
|
+
// 2026-07-12): entity values pass as their display name (the template's
|
|
1167
|
+
// article hint does the rest); scalars pass through.
|
|
1168
|
+
for (const p of stmtParams ?? []) {
|
|
1169
|
+
const value = this.evaluator.evalValue(p.value, ctx);
|
|
1170
|
+
const asEntity = typeof value === 'string' ? ctx.world.getEntity(value) : undefined;
|
|
1171
|
+
params[p.param] = asEntity ? asEntity.name : value;
|
|
1172
|
+
}
|
|
1173
|
+
for (const variant of phrase.variants) {
|
|
1174
|
+
for (const marker of variant.markers) {
|
|
1175
|
+
const producer = this.host.producers.get(marker);
|
|
1176
|
+
if (producer) {
|
|
1177
|
+
// Params carry phrase ATOMS, not functions — the template binder
|
|
1178
|
+
// string-coerces anything that isn't a Phrase (ADR-196: producers
|
|
1179
|
+
// are invoked at staging, their atoms realized by the assembler).
|
|
1180
|
+
// The context is the narrow staging facade (design.md §5.6): a
|
|
1181
|
+
// producer reaching outside it fails HERE, named, not as an
|
|
1182
|
+
// anonymous TypeError downstream.
|
|
1183
|
+
try {
|
|
1184
|
+
params[marker] = producer((0, hatch_context_1.stagingRenderContext)(ctx.world));
|
|
1185
|
+
}
|
|
1186
|
+
catch (error) {
|
|
1187
|
+
throw new errors_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
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// Grammar-slot params (`{the target}` in a dispatch-action or trait
|
|
1193
|
+
// clause body, zoo-chain fixes 2026-07-12): the slot entity's name
|
|
1194
|
+
// binds as the NounPhrase-default string — the template's own article
|
|
1195
|
+
// hint supplies `the`/`a`. Producers above win on a name collision.
|
|
1196
|
+
if (ctx.slots) {
|
|
1197
|
+
for (const [name, worldId] of Object.entries(ctx.slots)) {
|
|
1198
|
+
if (params[name] !== undefined)
|
|
1199
|
+
continue;
|
|
1200
|
+
const slotEntity = ctx.world.getEntity(worldId);
|
|
1201
|
+
if (slotEntity)
|
|
1202
|
+
params[name] = slotEntity.name;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (phrase.verbatim) {
|
|
1206
|
+
// `{verbatim:text}` template (loader registration) — the atom is
|
|
1207
|
+
// exempt from whitespace collapse, so line structure and interior
|
|
1208
|
+
// spacing survive as authored (grammar log 2026-07-10).
|
|
1209
|
+
params.text = phrase.variants[0]?.text ?? '';
|
|
1210
|
+
}
|
|
1211
|
+
else if (phrase.strategy) {
|
|
1212
|
+
const choice = {
|
|
1213
|
+
kind: 'choice',
|
|
1214
|
+
alternatives: phrase.variants.map((v) => ({ kind: 'literal', text: (0, text_1.withLineBreaks)(v.text) })),
|
|
1215
|
+
selector: exports.STRATEGY_SELECTOR[phrase.strategy],
|
|
1216
|
+
entityId: counter?.entityId ?? 'chord',
|
|
1217
|
+
messageKey: counter?.messageKey ?? overrideKey,
|
|
1218
|
+
};
|
|
1219
|
+
params.variants = choice;
|
|
1220
|
+
}
|
|
1221
|
+
return this.rawEvent('chord.phrase', { messageId: overrideKey, params });
|
|
1222
|
+
}
|
|
1223
|
+
rawEvent(type, data) {
|
|
1224
|
+
return {
|
|
1225
|
+
id: `chord-${type}-${this.eventSeq++}`,
|
|
1226
|
+
type,
|
|
1227
|
+
timestamp: Date.now(),
|
|
1228
|
+
entities: {},
|
|
1229
|
+
data,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
// --------------------------------------------------------------- helpers
|
|
1233
|
+
irIdOfValue(value, ctx) {
|
|
1234
|
+
if (value.kind === 'entity')
|
|
1235
|
+
return value.id;
|
|
1236
|
+
if (value.kind === 'it') {
|
|
1237
|
+
if (!ctx.it)
|
|
1238
|
+
throw new errors_1.LoadError('`it` used outside an entity-scoped clause.');
|
|
1239
|
+
return ctx.it;
|
|
1240
|
+
}
|
|
1241
|
+
const worldId = this.evaluator.entityValue(value, ctx);
|
|
1242
|
+
const irId = this.host.irIdOf(worldId);
|
|
1243
|
+
if (!irId)
|
|
1244
|
+
throw new errors_1.LoadError('Cannot change the state of a non-story entity.');
|
|
1245
|
+
return irId;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
exports.ChordRuntime = ChordRuntime;
|
|
1249
|
+
//# sourceMappingURL=runtime.js.map
|