@sharpee/chord 3.1.0 → 3.5.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/analyzer.d.ts +11 -3
- package/analyzer.d.ts.map +1 -1
- package/analyzer.js +1360 -33
- package/analyzer.js.map +1 -1
- package/ast.d.ts +480 -9
- package/ast.d.ts.map +1 -1
- package/catalog.d.ts +57 -1
- package/catalog.d.ts.map +1 -1
- package/catalog.js +91 -3
- package/catalog.js.map +1 -1
- package/diagnostics.d.ts +1 -1
- package/diagnostics.d.ts.map +1 -1
- package/index.d.ts +32 -13
- package/index.d.ts.map +1 -1
- package/index.js +112 -30
- package/index.js.map +1 -1
- package/ir.d.ts +361 -7
- package/ir.d.ts.map +1 -1
- package/lexer.d.ts +11 -3
- package/lexer.d.ts.map +1 -1
- package/lexer.js +36 -8
- package/lexer.js.map +1 -1
- package/manifests/combat.d.ts +16 -0
- package/manifests/combat.d.ts.map +1 -0
- package/manifests/combat.js +38 -0
- package/manifests/combat.js.map +1 -0
- package/manifests/hunger.d.ts +18 -0
- package/manifests/hunger.d.ts.map +1 -0
- package/manifests/hunger.js +8 -0
- package/manifests/hunger.js.map +1 -0
- package/manifests/index.d.ts +23 -0
- package/manifests/index.d.ts.map +1 -0
- package/manifests/index.js +52 -0
- package/manifests/index.js.map +1 -0
- package/manifests/npc.d.ts +19 -0
- package/manifests/npc.d.ts.map +1 -0
- package/manifests/npc.js +38 -0
- package/manifests/npc.js.map +1 -0
- package/manifests/scoring.d.ts +25 -0
- package/manifests/scoring.d.ts.map +1 -0
- package/manifests/scoring.js +8 -0
- package/manifests/scoring.js.map +1 -0
- package/manifests/state-machines.d.ts +17 -0
- package/manifests/state-machines.d.ts.map +1 -0
- package/manifests/state-machines.js +8 -0
- package/manifests/state-machines.js.map +1 -0
- package/manifests/types.d.ts +41 -0
- package/manifests/types.d.ts.map +1 -0
- package/manifests/types.js +18 -0
- package/manifests/types.js.map +1 -0
- package/message-alias-catalog.d.ts +22 -0
- package/message-alias-catalog.d.ts.map +1 -0
- package/message-alias-catalog.js +826 -0
- package/message-alias-catalog.js.map +1 -0
- package/package.json +1 -1
- package/parser.d.ts +2 -2
- package/parser.d.ts.map +1 -1
- package/parser.js +1644 -131
- package/parser.js.map +1 -1
- package/phrasebooks.d.ts +28 -0
- package/phrasebooks.d.ts.map +1 -0
- package/phrasebooks.js +6 -0
- package/phrasebooks.js.map +1 -0
- package/version.d.ts +38 -0
- package/version.d.ts.map +1 -0
- package/version.js +41 -0
- package/version.js.map +1 -0
- package/sharpee-chord-3.0.0.tgz +0 -0
package/analyzer.js
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeTopic = normalizeTopic;
|
|
3
4
|
exports.analyze = analyze;
|
|
4
|
-
const
|
|
5
|
-
const
|
|
5
|
+
const catalog_js_1 = require("./catalog.js");
|
|
6
|
+
const index_js_1 = require("./manifests/index.js");
|
|
7
|
+
const phrasebooks_js_1 = require("./phrasebooks.js");
|
|
8
|
+
const version_js_1 = require("./version.js");
|
|
9
|
+
const ir_js_1 = require("./ir.js");
|
|
6
10
|
/** Phase A stories register text in this locale (design.md §2.6). */
|
|
7
11
|
const DEFAULT_LOCALE = 'en-US';
|
|
12
|
+
/**
|
|
13
|
+
* Kebab-case a quoted author string into a story key (ADR-254).
|
|
14
|
+
*
|
|
15
|
+
* Used to derive a rank's id from its name, so a rank is addressable in
|
|
16
|
+
* diagnostics and in `if.event.band_crossed` without the author declaring one.
|
|
17
|
+
*/
|
|
18
|
+
function kebabId(name) {
|
|
19
|
+
return name
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
22
|
+
.replace(/^-+|-+$/g, '');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Exit-direction opposites (parser DIRECTIONS vocabulary) — the same
|
|
26
|
+
* inference every plain exit line performs platform-side; here it backs
|
|
27
|
+
* the door mirror-line check (ADR-234 D2/D3, `checkDoors`).
|
|
28
|
+
*/
|
|
29
|
+
const OPPOSITE_DIRECTION = {
|
|
30
|
+
north: 'south', south: 'north', east: 'west', west: 'east',
|
|
31
|
+
northeast: 'southwest', southwest: 'northeast',
|
|
32
|
+
northwest: 'southeast', southeast: 'northwest',
|
|
33
|
+
up: 'down', down: 'up',
|
|
34
|
+
};
|
|
8
35
|
const PLAYER_WORDS = new Set(['player', 'you', 'yourself']);
|
|
9
36
|
/** Reserved-name gate text (David, 2026-07-12 — each package P3). */
|
|
10
37
|
const RESERVED_MATCH_MESSAGE = '`match` is reserved for the `each`-block binder `the match` (ratchet E3) — pick another name.';
|
|
@@ -15,8 +42,27 @@ const RESERVED_MATCH_MESSAGE = '`match` is reserved for the `each`-block binder
|
|
|
15
42
|
* block is the one authoring surface.
|
|
16
43
|
*/
|
|
17
44
|
const RESERVED_CHANNEL_KEYS = new Set(['present', 'entered', 'exited', 'disappeared', 'detail']);
|
|
45
|
+
/**
|
|
46
|
+
* Image layers the platform pre-registers (`image:background|main|
|
|
47
|
+
* overlay`, stdlib MEDIA_CHANNELS) — implied like the `main` ambient
|
|
48
|
+
* bed; `define layer` covers only layers beyond these (ADR-241 D3).
|
|
49
|
+
*/
|
|
50
|
+
const IMPLIED_IMAGE_LAYERS = new Set(['background', 'main', 'overlay']);
|
|
18
51
|
/** Ring 1 of the boolean-state gate (D9): literal booleans as state names. */
|
|
19
52
|
const BOOLEAN_STATE_WORDS = new Set(['true', 'false', 'yes', 'no']);
|
|
53
|
+
/**
|
|
54
|
+
* ADR-239 D4 topic normalization — ONE implementation for both halves of
|
|
55
|
+
* the lookup contract: the analyzer's overlap gates here, and the
|
|
56
|
+
* story-loader's runtime table lookup (which imports this). Rules:
|
|
57
|
+
* case-insensitive, leading article stripped, whitespace collapsed.
|
|
58
|
+
* Whole-topic equality only; never substring matching.
|
|
59
|
+
*/
|
|
60
|
+
function normalizeTopic(text) {
|
|
61
|
+
const words = text.trim().toLowerCase().split(/\s+/);
|
|
62
|
+
if (words.length > 1 && (words[0] === 'the' || words[0] === 'a' || words[0] === 'an'))
|
|
63
|
+
words.shift();
|
|
64
|
+
return words.join(' ');
|
|
65
|
+
}
|
|
20
66
|
/**
|
|
21
67
|
* Negation prefixes/suffix for ring 3 of the boolean-state gate (D9):
|
|
22
68
|
* `not-`/`un-`/`non-` (hyphenated or fused), `no-` (hyphenated only — bare
|
|
@@ -61,6 +107,8 @@ function conditionFingerprint(cond) {
|
|
|
61
107
|
return `not(${conditionFingerprint(cond.operand)})`;
|
|
62
108
|
case 'chance':
|
|
63
109
|
return `chance:${cond.n}`;
|
|
110
|
+
case 'client-has':
|
|
111
|
+
return `client-has:${cond.capability}`;
|
|
64
112
|
case 'condition-ref':
|
|
65
113
|
return `cond:${cond.name}`;
|
|
66
114
|
case 'any-of':
|
|
@@ -97,6 +145,8 @@ const STANDARD_ACTION_ROLES = {
|
|
|
97
145
|
taking: ['taker', 'item'],
|
|
98
146
|
};
|
|
99
147
|
const TOP_SCOPE = { owner: null, fields: null, slots: null, ownStates: null, scoreOwner: null, inEach: false };
|
|
148
|
+
/** Scope of a story-owned clause (ADR-236 D7): no owner, `it` unbound. */
|
|
149
|
+
const STORY_SCOPE = { ...TOP_SCOPE, storyOwned: true };
|
|
100
150
|
function entityScope(owner) {
|
|
101
151
|
return { owner, fields: null, slots: null, ownStates: null, scoreOwner: owner?.id ?? null, inEach: false };
|
|
102
152
|
}
|
|
@@ -139,6 +189,7 @@ function conditionReferencesIt(cond) {
|
|
|
139
189
|
return conditionReferencesIt(cond.operand);
|
|
140
190
|
case 'chance':
|
|
141
191
|
case 'condition-ref':
|
|
192
|
+
case 'client-has':
|
|
142
193
|
return false;
|
|
143
194
|
case 'any-of':
|
|
144
195
|
case 'none-of':
|
|
@@ -187,10 +238,20 @@ class Analyzer {
|
|
|
187
238
|
hatchNames = new Set();
|
|
188
239
|
/** locale → key → IRPhrase */
|
|
189
240
|
phrases = new Map();
|
|
241
|
+
/** ADR-255: locale → override alias → IRPhrase (validated against MESSAGE_OVERRIDE_ALIASES). */
|
|
242
|
+
messageOverrides = new Map();
|
|
190
243
|
// Phase B namespaces:
|
|
191
244
|
traitNames = new Set();
|
|
192
245
|
/** action name → declared grammar-slot names. */
|
|
193
246
|
actionSlots = new Map();
|
|
247
|
+
/**
|
|
248
|
+
* emit event id (dotless) → the top-level payload field names it carries
|
|
249
|
+
* (ADR-253 D1). Collected across every `emit` during resolution so
|
|
250
|
+
* `checkChannelReturns` can flag a channel returning a field the event never
|
|
251
|
+
* emits. An event with no collected entry (a platform event, or one never
|
|
252
|
+
* emitted here) is left unchecked — the field set is unknown, not empty.
|
|
253
|
+
*/
|
|
254
|
+
emitFields = new Map();
|
|
194
255
|
/** Owner-qualified score id (`pygmy-goats.fed`, story-level bare) → worth. */
|
|
195
256
|
scoreNames = new Map();
|
|
196
257
|
/** Owner-qualified score declarations, for ir.scores emission. */
|
|
@@ -210,32 +271,117 @@ class Analyzer {
|
|
|
210
271
|
* feedable's `hungry` — resolution is across the composer's trait set).
|
|
211
272
|
*/
|
|
212
273
|
traitVisibleStates = new Map();
|
|
274
|
+
/**
|
|
275
|
+
* `define pronouns` sets by name (ADR-242 D7), collected in pass 1 so a
|
|
276
|
+
* `pronouns <word>` line resolves against sets declared later in the
|
|
277
|
+
* file. Span doubles as the pass-2 emission guard (family-channel
|
|
278
|
+
* precedent: an errored duplicate never emits a second entry).
|
|
279
|
+
*/
|
|
280
|
+
pronounSetDecls = new Map();
|
|
213
281
|
constructor(ast, diagnostics) {
|
|
214
282
|
this.ast = ast;
|
|
215
283
|
this.diagnostics = diagnostics;
|
|
216
284
|
}
|
|
285
|
+
/** Extensions admitted by validated `use` lines (ADR-215). */
|
|
286
|
+
usedExtensions = new Set();
|
|
287
|
+
/**
|
|
288
|
+
* Phrasebooks in arbitration order (ADR-250 D3): header `use phrasebook`
|
|
289
|
+
* lines first (file position), then body `define phrasebook` blocks in
|
|
290
|
+
* declaration order. Conditions resolve in run() (pass 2), after every
|
|
291
|
+
* entity symbol exists.
|
|
292
|
+
*/
|
|
293
|
+
phrasebookDecls = [];
|
|
294
|
+
/** Book name → declaring span (`analysis.duplicate-phrasebook` gate). */
|
|
295
|
+
phrasebookNames = new Map();
|
|
296
|
+
/**
|
|
297
|
+
* Story key → true when a predicate-less (default/always) book covers
|
|
298
|
+
* it. Book coverage counts as declaration for the missing-phrase gate
|
|
299
|
+
* (ADR-250 D4.6); predicated-only coverage earns the partial-coverage
|
|
300
|
+
* warning at first reference.
|
|
301
|
+
*/
|
|
302
|
+
bookKeys = new Map();
|
|
303
|
+
partialCoverageWarned = new Set();
|
|
217
304
|
run() {
|
|
218
305
|
this.collect();
|
|
306
|
+
// ADR-215: validate `use` lines against the manifest registry — an
|
|
307
|
+
// unknown name is a compile error (the loader's trusted registry check
|
|
308
|
+
// backstops rogue IR), a duplicate is one-`use`-per-extension.
|
|
309
|
+
const announceModes = {};
|
|
310
|
+
const VALID_ANNOUNCE_MODES = ['all', 'collapsed', 'combined', 'silent'];
|
|
311
|
+
for (const use of this.ast.header?.uses ?? []) {
|
|
312
|
+
// ADR-262 D3: validate the `, announce <mode>` suffix and record it.
|
|
313
|
+
if (use.announce !== undefined) {
|
|
314
|
+
if (!VALID_ANNOUNCE_MODES.includes(use.announce)) {
|
|
315
|
+
this.diagnostics.error('analysis.invalid-announce-mode', `\`announce ${use.announce}\` is not a mode — use one of: ${VALID_ANNOUNCE_MODES.join(', ')}.`, use.span);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
announceModes[use.name] = use.announce;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const manifest = index_js_1.EXTENSION_MANIFESTS.get(use.name);
|
|
322
|
+
if (!manifest) {
|
|
323
|
+
const gated = [...index_js_1.EXTENSION_MANIFESTS.values()].filter((m) => !m.core).map((m) => m.name);
|
|
324
|
+
this.diagnostics.error('analysis.unknown-extension', `\`use ${use.name}\` names no trusted extension — known: ${gated.join(', ')}.`, use.span);
|
|
325
|
+
}
|
|
326
|
+
else if (manifest.core) {
|
|
327
|
+
// ADR-215 Q4: NPC vocabulary is CORE — always on, never `use`d.
|
|
328
|
+
this.diagnostics.error('analysis.extension-core', `\`${use.name}\` vocabulary is core — it is always available; remove the \`use\` line.`, use.span);
|
|
329
|
+
}
|
|
330
|
+
else if (this.usedExtensions.has(use.name)) {
|
|
331
|
+
this.diagnostics.error('analysis.duplicate-use', `\`use ${use.name}\` is already declared — one \`use\` per extension.`, use.span);
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
this.usedExtensions.add(use.name);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// ADR-261 D4: scoring's constructs sit behind `use scoring`. Gating them
|
|
338
|
+
// together is what makes D3 ("absent `use scoring` means the game has no
|
|
339
|
+
// score") a rule with no exceptions — scoring is on precisely when the
|
|
340
|
+
// header says so, and there is one place to look. Reported once per
|
|
341
|
+
// construct kind, at the first offending site, rather than once per line.
|
|
342
|
+
if (!this.usedExtensions.has('scoring') && this.scoreDecls.length > 0) {
|
|
343
|
+
this.reportScoringGate('score', this.scoreDecls[0].span);
|
|
344
|
+
}
|
|
345
|
+
// Built once (it emits diagnostics), spread in only when present so the
|
|
346
|
+
// optional `hunger` field never appears as `undefined` on a story without it.
|
|
347
|
+
const hungerDef = this.buildHunger();
|
|
219
348
|
const ir = {
|
|
220
|
-
format:
|
|
349
|
+
format: ir_js_1.IR_FORMAT,
|
|
350
|
+
languageVersion: version_js_1.CHORD_LANGUAGE_VERSION, // ADR-257 D3 — the language version that compiled this story
|
|
221
351
|
meta: {
|
|
222
352
|
title: this.ast.header?.title ?? '',
|
|
223
353
|
author: this.ast.header?.author ?? '',
|
|
224
354
|
fields: this.ast.header?.fields ?? {},
|
|
225
355
|
},
|
|
356
|
+
uses: [...this.usedExtensions],
|
|
357
|
+
announceModes,
|
|
226
358
|
story: {
|
|
227
359
|
states: this.storyStates,
|
|
228
360
|
reversible: this.ast.header?.statesReversible ?? false,
|
|
361
|
+
// Story-owned every-turn clauses (ADR-236 D7): built in STORY_SCOPE
|
|
362
|
+
// so `it` reports the unbound-referent gate; narration broadcasts
|
|
363
|
+
// (the story is everywhere — D11 satisfied trivially).
|
|
364
|
+
onClauses: (this.ast.header?.onClauses ?? []).map((c) => ({
|
|
365
|
+
...this.buildOnClause(c, STORY_SCOPE),
|
|
366
|
+
narration: 'broadcast',
|
|
367
|
+
})),
|
|
229
368
|
},
|
|
230
369
|
entities: [],
|
|
231
370
|
conditions: [],
|
|
232
371
|
phrases: { defaultLocale: DEFAULT_LOCALE, locales: {} },
|
|
372
|
+
messageOverrides: { defaultLocale: DEFAULT_LOCALE, locales: {} },
|
|
373
|
+
phrasebooks: [],
|
|
233
374
|
verbs: [],
|
|
234
375
|
hatches: [],
|
|
235
376
|
traits: [],
|
|
236
377
|
actions: [],
|
|
237
378
|
scores: this.scoreDecls,
|
|
379
|
+
ranks: this.buildRanks(),
|
|
380
|
+
...(hungerDef !== undefined ? { hunger: hungerDef } : {}),
|
|
238
381
|
sequences: [],
|
|
382
|
+
machines: [],
|
|
383
|
+
channels: [],
|
|
384
|
+
pronounSets: [],
|
|
239
385
|
hasHatches: false,
|
|
240
386
|
};
|
|
241
387
|
for (const decl of this.ast.declarations) {
|
|
@@ -262,6 +408,10 @@ class Analyzer {
|
|
|
262
408
|
ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: 'text', span: decl.span });
|
|
263
409
|
break;
|
|
264
410
|
case 'define-hatch':
|
|
411
|
+
// ADR-094 chain hatch: the name must be a replaceable stdlib chain.
|
|
412
|
+
if (decl.hatchKind === 'chain' && !catalog_js_1.STDLIB_CHAIN_NAMES.has(decl.name)) {
|
|
413
|
+
this.diagnostics.error('analysis.unknown-chain', `\`${decl.name}\` is not a replaceable stdlib chain${this.suggestText(decl.name, [...catalog_js_1.STDLIB_CHAIN_NAMES])}.`, decl.span);
|
|
414
|
+
}
|
|
265
415
|
ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: decl.hatchKind, span: decl.span });
|
|
266
416
|
break;
|
|
267
417
|
case 'define-trait':
|
|
@@ -270,6 +420,29 @@ class Analyzer {
|
|
|
270
420
|
case 'define-action':
|
|
271
421
|
ir.actions.push(this.buildAction(decl));
|
|
272
422
|
break;
|
|
423
|
+
case 'define-machine':
|
|
424
|
+
ir.machines.push(this.buildMachine(decl));
|
|
425
|
+
break;
|
|
426
|
+
case 'define-asset':
|
|
427
|
+
break; // collected in pass 1 — data references, nothing to emit
|
|
428
|
+
case 'define-family-channel':
|
|
429
|
+
// ADR-241 D2/D4: the declaration joins the channel manifest.
|
|
430
|
+
// The pass-1 span guard keeps an errored duplicate from
|
|
431
|
+
// producing a second entry.
|
|
432
|
+
if (this.familyChannels[decl.family].get(decl.name) === decl.span) {
|
|
433
|
+
ir.channels.push({ name: decl.name, family: decl.family, span: decl.span });
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
case 'define-channel':
|
|
437
|
+
ir.channels.push(this.buildChannel(decl));
|
|
438
|
+
break;
|
|
439
|
+
case 'define-pronouns':
|
|
440
|
+
// ADR-242 D7 — the pass-1 span guard keeps a shadowing/duplicate
|
|
441
|
+
// declaration from emitting (family-channel precedent).
|
|
442
|
+
if (this.pronounSetDecls.get(decl.name) === decl) {
|
|
443
|
+
ir.pronounSets.push(this.buildPronounSet(decl));
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
273
446
|
case 'define-sequence':
|
|
274
447
|
ir.sequences.push({
|
|
275
448
|
name: decl.name.join(' '),
|
|
@@ -295,18 +468,421 @@ class Analyzer {
|
|
|
295
468
|
entry.condition = this.resolveCondition(decl.condition, TOP_SCOPE);
|
|
296
469
|
}
|
|
297
470
|
break;
|
|
471
|
+
case 'override-message':
|
|
472
|
+
// ADR-255: same two-pass split as define-phrase — the optional
|
|
473
|
+
// `while <cond>` gate resolves here in pass 2. Only set when the
|
|
474
|
+
// alias survived pass-1 validation (an entry exists).
|
|
475
|
+
if (decl.condition) {
|
|
476
|
+
const entry = this.messageOverrides.get(DEFAULT_LOCALE)?.get(decl.alias);
|
|
477
|
+
if (entry)
|
|
478
|
+
entry.condition = this.resolveCondition(decl.condition, TOP_SCOPE);
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
case 'override-messages':
|
|
482
|
+
break; // flat entries collected in pass 1; no per-entry condition
|
|
298
483
|
case 'define-phrases':
|
|
299
484
|
break; // collected in pass 1
|
|
485
|
+
case 'define-phrasebook':
|
|
486
|
+
case 'import':
|
|
487
|
+
break; // collected/diagnosed in pass 1; conditions resolve below
|
|
488
|
+
case 'define-topics':
|
|
489
|
+
break; // applied onto owners after all entities are built (applyTopics)
|
|
300
490
|
}
|
|
301
491
|
}
|
|
492
|
+
// ADR-250 D3: books in arbitration order; predicates resolve here in
|
|
493
|
+
// pass 2 (an entity declared after the book may appear in its `while`).
|
|
494
|
+
ir.phrasebooks = this.phrasebookDecls.map((b) => ({
|
|
495
|
+
name: b.name,
|
|
496
|
+
source: b.source,
|
|
497
|
+
condition: b.condition ? this.resolveCondition(b.condition, TOP_SCOPE) : null,
|
|
498
|
+
...(b.entries ? { entries: b.entries } : {}),
|
|
499
|
+
span: b.span,
|
|
500
|
+
}));
|
|
501
|
+
// ADR-241 D3/D4: the implied `main` ambient bed — used by an ambient
|
|
502
|
+
// statement without a declaration — joins the channel manifest so the
|
|
503
|
+
// loader registers it (nothing platform-side pre-registers ambient).
|
|
504
|
+
if (this.impliedMainBedSpan && !this.familyChannels.ambient.has('main')) {
|
|
505
|
+
ir.channels.push({ name: 'main', family: 'ambient', span: this.impliedMainBedSpan });
|
|
506
|
+
}
|
|
302
507
|
for (const [locale, table] of this.phrases) {
|
|
303
508
|
ir.phrases.locales[locale] = Object.fromEntries(table);
|
|
304
509
|
}
|
|
510
|
+
for (const [locale, table] of this.messageOverrides) {
|
|
511
|
+
ir.messageOverrides.locales[locale] = Object.fromEntries(table);
|
|
512
|
+
}
|
|
305
513
|
ir.hasHatches = ir.hatches.length > 0;
|
|
514
|
+
this.applyTopics(ir.entities);
|
|
515
|
+
this.checkRegions(ir.entities);
|
|
516
|
+
this.checkDoors(ir.entities);
|
|
306
517
|
this.checkMarkers();
|
|
307
518
|
this.checkDescriptionMarkers();
|
|
519
|
+
this.checkChannelReturns(ir.channels); // ADR-253 D1: return-field cross-check (all emits collected)
|
|
308
520
|
return ir;
|
|
309
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* ADR-236 D2/D3 never-guess gates over the whole region graph: member
|
|
524
|
+
* kinds (rooms or regions only), single direct membership (RoomTrait's
|
|
525
|
+
* `regionId` is single-valued — an ancestor+descendant listing is the
|
|
526
|
+
* same error), single parent per region, no memberless regions, no
|
|
527
|
+
* containment cycles. Runs after every entity is built so cross-entity
|
|
528
|
+
* lookups and spans are all available.
|
|
529
|
+
*/
|
|
530
|
+
checkRegions(entities) {
|
|
531
|
+
const byId = new Map(entities.map((e) => [e.id, e]));
|
|
532
|
+
const isRegionEntity = (e) => e.kinds.some((k) => k.name === 'region');
|
|
533
|
+
const regions = entities.filter(isRegionEntity);
|
|
534
|
+
// Memberless region: declared-but-unanswerable, uniformly hard (D2 —
|
|
535
|
+
// ruled no warning tier; its daemon could otherwise silently never fire).
|
|
536
|
+
for (const region of regions) {
|
|
537
|
+
if (region.containing.length === 0) {
|
|
538
|
+
this.diagnostics.error('analysis.region-memberless', `Region \`${region.name}\` has no \`containing\` line — an empty region is unanswerable (its daemons and crossings could never fire). List its member rooms.`, region.span);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Direct membership is stated exactly once, graph-wide.
|
|
542
|
+
const roomMemberOf = new Map();
|
|
543
|
+
const parentOf = new Map();
|
|
544
|
+
for (const region of regions) {
|
|
545
|
+
for (const member of region.containing) {
|
|
546
|
+
const target = byId.get(member.id);
|
|
547
|
+
if (!target)
|
|
548
|
+
continue; // unresolved — already reported by resolveEntityId
|
|
549
|
+
if (isRegionEntity(target)) {
|
|
550
|
+
const prior = parentOf.get(member.id);
|
|
551
|
+
if (prior) {
|
|
552
|
+
this.diagnostics.error('analysis.region-two-parents', `Region \`${target.name}\` is already contained by region \`${prior.parent.name}\` (line ${prior.span.line}) — a region has exactly one parent.`, member.span);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
parentOf.set(member.id, { parent: region, span: member.span });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else if (target.kinds.some((k) => k.name === 'room')) {
|
|
559
|
+
const prior = roomMemberOf.get(member.id);
|
|
560
|
+
if (prior) {
|
|
561
|
+
this.diagnostics.error('analysis.region-double-membership', `\`${target.name}\` is already a member of region \`${prior.region.name}\` (line ${prior.span.line}) — direct membership is stated exactly once (nesting already makes a room part of every ancestor region).`, member.span);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
roomMemberOf.set(member.id, { region, span: member.span });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
const kind = target.kinds[0]?.name ?? 'plain thing';
|
|
569
|
+
this.diagnostics.error('analysis.region-member-kind', `\`${target.name}\` is a ${kind} — \`containing\` members must be rooms or regions.`, member.span);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Containment cycles (walking child → parent; two-parents kept the
|
|
574
|
+
// first edge, so the graph is functional and one walk per region ends).
|
|
575
|
+
const walked = new Map();
|
|
576
|
+
for (const region of regions) {
|
|
577
|
+
if (walked.has(region.id))
|
|
578
|
+
continue;
|
|
579
|
+
const path = [];
|
|
580
|
+
let cur = region.id;
|
|
581
|
+
while (cur !== undefined && !walked.has(cur)) {
|
|
582
|
+
walked.set(cur, 'visiting');
|
|
583
|
+
path.push(cur);
|
|
584
|
+
const edge = parentOf.get(cur);
|
|
585
|
+
const next = edge?.parent.id;
|
|
586
|
+
if (next !== undefined && walked.get(next) === 'visiting') {
|
|
587
|
+
const names = [...path.slice(path.indexOf(next)), next].map((id) => byId.get(id)?.name ?? id);
|
|
588
|
+
this.diagnostics.error('analysis.region-cycle', `Region containment cycle: ${names.map((n) => `\`${n}\``).join(' → ')}.`, edge.span);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
cur = next;
|
|
592
|
+
}
|
|
593
|
+
for (const id of path)
|
|
594
|
+
walked.set(id, 'done');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* ADR-234 D3 never-guess gates over the whole door graph: every
|
|
599
|
+
* `through` target is a door, a door connects exactly one room pair
|
|
600
|
+
* (the only legal second reference is the exact mirror — other side,
|
|
601
|
+
* opposite direction), and every declared door is referenced somewhere
|
|
602
|
+
* (an unconnected door is unanswerable, same hard class as a
|
|
603
|
+
* memberless region). Runs after every entity is built so cross-entity
|
|
604
|
+
* lookups and spans are all available.
|
|
605
|
+
*/
|
|
606
|
+
checkDoors(entities) {
|
|
607
|
+
const byId = new Map(entities.map((e) => [e.id, e]));
|
|
608
|
+
const isDoorEntity = (e) => e.kinds.some((k) => k.name === 'door');
|
|
609
|
+
// First `through` reference per door — the canonical pair.
|
|
610
|
+
const firstRef = new Map();
|
|
611
|
+
// Doors whose mirror side has already been stated.
|
|
612
|
+
const mirrored = new Set();
|
|
613
|
+
for (const owner of entities) {
|
|
614
|
+
for (const exit of owner.exits) {
|
|
615
|
+
if (exit.via === null || exit.via === '')
|
|
616
|
+
continue; // plain, or unresolved (already reported)
|
|
617
|
+
const door = byId.get(exit.via);
|
|
618
|
+
if (!door)
|
|
619
|
+
continue;
|
|
620
|
+
if (!isDoorEntity(door)) {
|
|
621
|
+
const kind = door.kinds[0]?.name ?? 'plain thing';
|
|
622
|
+
this.diagnostics.error('analysis.door-through-kind', `\`${door.name}\` is a ${kind} — \`through\` names a door (\`create the ${door.name} / a door\`).`, exit.span);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const first = firstRef.get(door.id);
|
|
626
|
+
if (!first) {
|
|
627
|
+
firstRef.set(door.id, { owner, exit });
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const samePair = (owner.id === first.owner.id && exit.to === first.exit.to)
|
|
631
|
+
|| (owner.id === first.exit.to && exit.to === first.owner.id);
|
|
632
|
+
if (!samePair) {
|
|
633
|
+
this.diagnostics.error('analysis.door-multi-pair', `\`${door.name}\` already connects \`${first.owner.name}\` and \`${byId.get(first.exit.to)?.name ?? first.exit.to}\` (line ${first.exit.span.line}) — a door connects exactly two rooms.`, exit.span);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const isMirror = owner.id === first.exit.to
|
|
637
|
+
&& exit.to === first.owner.id
|
|
638
|
+
&& exit.direction === OPPOSITE_DIRECTION[first.exit.direction]
|
|
639
|
+
&& !mirrored.has(door.id);
|
|
640
|
+
if (isMirror) {
|
|
641
|
+
mirrored.add(door.id);
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
this.diagnostics.error('analysis.door-pair-mismatch', `\`${door.name}\` is already wired by \`${first.owner.name}\`'s \`${first.exit.direction}\` line (line ${first.exit.span.line}) — the only legal second reference is the exact mirror (\`${OPPOSITE_DIRECTION[first.exit.direction] ?? '?'} to the ${first.owner.name} through the ${door.name}\` in \`${byId.get(first.exit.to)?.name ?? first.exit.to}\`), stated at most once.`, exit.span);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// Plain mirror of a door exit (platform-issue-sweep Phase 8 #6): a
|
|
649
|
+
// plain `<dir> to <room>` line whose reverse side is door-wired would
|
|
650
|
+
// re-stamp BOTH directions without the door at load, silently unwiring
|
|
651
|
+
// it (the loader's connectRooms stamps the reverse too). The author
|
|
652
|
+
// must name the door — or drop the line entirely, since one door-wired
|
|
653
|
+
// side already connects both rooms.
|
|
654
|
+
for (const owner of entities) {
|
|
655
|
+
for (const exit of owner.exits) {
|
|
656
|
+
if (exit.via !== null)
|
|
657
|
+
continue;
|
|
658
|
+
const target = byId.get(exit.to);
|
|
659
|
+
if (!target)
|
|
660
|
+
continue;
|
|
661
|
+
const reverse = target.exits.find((e) => e.via !== null && e.via !== '' && e.to === owner.id
|
|
662
|
+
&& e.direction === OPPOSITE_DIRECTION[exit.direction]);
|
|
663
|
+
if (reverse) {
|
|
664
|
+
const doorName = byId.get(reverse.via)?.name ?? reverse.via;
|
|
665
|
+
this.diagnostics.error('analysis.door-plain-mirror', `\`${owner.name}\`'s plain \`${exit.direction}\` line mirrors a door exit — \`${target.name}\` wires \`${reverse.direction}\` through \`${doorName}\` (line ${reverse.span.line}), and a plain mirror would silently unwire the door at load. Name the door (\`${exit.direction} to the ${target.name} through the ${doorName}\`) or drop this line (the door side already connects both rooms).`, exit.span);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Unconnected door: declared-but-unanswerable, uniformly hard (D3 —
|
|
670
|
+
// same class as region-memberless; its room pair could never resolve).
|
|
671
|
+
for (const door of entities.filter(isDoorEntity)) {
|
|
672
|
+
if (!firstRef.has(door.id)) {
|
|
673
|
+
this.diagnostics.error('analysis.door-unconnected', `Door \`${door.name}\` is never referenced by a \`through\` exit line — an unconnected door is unanswerable (its room pair could never resolve). Add \`<direction> to the <room> through the ${door.name}\` on a room.`, door.span);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* ADR-239 D4's never-guess table gates + lowering: resolve each
|
|
679
|
+
* `define topics` block onto its owner entity's `topics` rows. Runs
|
|
680
|
+
* after every entity is built so owner kinds and cross-tier name
|
|
681
|
+
* lookups are all available. Gates (each its own diagnostic): a second
|
|
682
|
+
* block for the same owner, a non-person host, a duplicate topic
|
|
683
|
+
* (entity or normalized quoted text, aliases included), and a quoted
|
|
684
|
+
* entry colliding with the name/aka of an entity used in an
|
|
685
|
+
* entity-tier row of the same table.
|
|
686
|
+
*/
|
|
687
|
+
applyTopics(entities) {
|
|
688
|
+
const byId = new Map(entities.map((e) => [e.id, e]));
|
|
689
|
+
/** owner id → first block's span (duplicate-block gate). */
|
|
690
|
+
const blockOwners = new Map();
|
|
691
|
+
for (const decl of this.ast.declarations) {
|
|
692
|
+
if (decl.kind !== 'define-topics')
|
|
693
|
+
continue;
|
|
694
|
+
if (decl.owner.words.length === 0)
|
|
695
|
+
continue; // header parse error already reported
|
|
696
|
+
const ownerId = this.resolveEntityId(decl.owner);
|
|
697
|
+
if (!ownerId)
|
|
698
|
+
continue; // unknown/ambiguous — standard errors already reported
|
|
699
|
+
const owner = byId.get(ownerId);
|
|
700
|
+
const sym = this.byId.get(ownerId) ?? null;
|
|
701
|
+
if (!owner)
|
|
702
|
+
continue;
|
|
703
|
+
const first = blockOwners.get(ownerId);
|
|
704
|
+
if (first) {
|
|
705
|
+
this.diagnostics.error('analysis.duplicate-topics-block', `\`${owner.name}\` already has a \`define topics\` block at line ${first.line} — the table lives in one place; merge the rows.`, decl.span);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
blockOwners.set(ownerId, decl.span);
|
|
709
|
+
if (!owner.kinds.some((k) => k.name === 'person')) {
|
|
710
|
+
const kind = owner.kinds[0] ? `a ${owner.kinds[0].name}` : 'a plain thing';
|
|
711
|
+
this.diagnostics.error('analysis.topics-host', `\`define topics\` needs a person — \`${owner.name}\` is ${kind}, and only people answer \`ask\`/\`tell\` (a table here could never be reached).`, decl.span);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
// Entity-tier refs resolve once, up front: the cross-tier collision
|
|
715
|
+
// gate must see every entity-tier row's names, even those declared
|
|
716
|
+
// AFTER a colliding quoted row.
|
|
717
|
+
const resolvedRefs = decl.rows.map((row) => row.filter.kind === 'entity' ? this.resolveEntityId(row.filter.ref) : null);
|
|
718
|
+
/** normalized name/aka of entity-tier row entities → display name. */
|
|
719
|
+
const entityTierNames = new Map();
|
|
720
|
+
for (const id of resolvedRefs) {
|
|
721
|
+
if (!id)
|
|
722
|
+
continue;
|
|
723
|
+
const rowSym = this.byId.get(id);
|
|
724
|
+
if (!rowSym)
|
|
725
|
+
continue;
|
|
726
|
+
entityTierNames.set(normalizeTopic(rowSym.nameLower), rowSym.nameLower);
|
|
727
|
+
for (const alias of rowSym.aka)
|
|
728
|
+
entityTierNames.set(normalizeTopic(alias), rowSym.nameLower);
|
|
729
|
+
}
|
|
730
|
+
const scope = entityScope(sym);
|
|
731
|
+
const rows = [];
|
|
732
|
+
const seenEntities = new Map();
|
|
733
|
+
const seenTexts = new Map();
|
|
734
|
+
for (let i = 0; i < decl.rows.length; i++) {
|
|
735
|
+
const row = decl.rows[i];
|
|
736
|
+
if (row.filter.kind === 'entity') {
|
|
737
|
+
const id = resolvedRefs[i];
|
|
738
|
+
if (!id)
|
|
739
|
+
continue; // unresolved — already reported
|
|
740
|
+
const dup = seenEntities.get(id);
|
|
741
|
+
if (dup) {
|
|
742
|
+
this.diagnostics.error('analysis.duplicate-topic', `\`${this.byId.get(id)?.nameLower ?? id}\` is already a topic of this table (line ${dup.line}) — a topic answers in one place; merge the rows.`, row.span);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
seenEntities.set(id, row.span);
|
|
746
|
+
rows.push({
|
|
747
|
+
filter: { kind: 'entity', id },
|
|
748
|
+
body: row.body.map((s) => this.resolveStatement(s, scope)),
|
|
749
|
+
span: row.span,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
const texts = [row.filter.primary, ...row.filter.aliases];
|
|
754
|
+
let rejected = false;
|
|
755
|
+
for (const text of texts) {
|
|
756
|
+
const norm = normalizeTopic(text);
|
|
757
|
+
const dup = seenTexts.get(norm);
|
|
758
|
+
if (dup) {
|
|
759
|
+
this.diagnostics.error('analysis.duplicate-topic', `"${text}" is already declared in this table (line ${dup.line}) — aliases included, a topic answers in one place.`, row.filter.span);
|
|
760
|
+
rejected = true;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
seenTexts.set(norm, row.span);
|
|
764
|
+
const collidesWith = entityTierNames.get(norm);
|
|
765
|
+
if (collidesWith) {
|
|
766
|
+
this.diagnostics.error('analysis.topic-entity-collision', `"${text}" collides with \`${collidesWith}\` — that entity is already an entity-tier row of this table, and the quoted spelling would shadow its quiet entity resolution. Remove one.`, row.filter.span);
|
|
767
|
+
rejected = true;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (rejected)
|
|
771
|
+
continue;
|
|
772
|
+
rows.push({
|
|
773
|
+
filter: { kind: 'text', primary: row.filter.primary, aliases: row.filter.aliases },
|
|
774
|
+
body: row.body.map((s) => this.resolveStatement(s, scope)),
|
|
775
|
+
span: row.span,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
this.checkPhaseOrder(row.body, { mutated: false });
|
|
779
|
+
}
|
|
780
|
+
owner.topics = rows;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** Machine names seen (duplicate gate). */
|
|
784
|
+
machineNames = new Set();
|
|
785
|
+
/** Declared media assets (ADR-216): name → kind + path. */
|
|
786
|
+
assets = new Map();
|
|
787
|
+
/**
|
|
788
|
+
* Build one `define machine` (ADR-215 `use state-machines` depth):
|
|
789
|
+
* gated on the `use`, states/targets/roles validated, single-word
|
|
790
|
+
* triggers resolved (declared condition or story state wins, else an
|
|
791
|
+
* action gerund), bodies in STORY_SCOPE (`it` unbound — the machine is
|
|
792
|
+
* story-owned).
|
|
793
|
+
*/
|
|
794
|
+
buildMachine(decl) {
|
|
795
|
+
if (!this.usedExtensions.has('state-machines')) {
|
|
796
|
+
this.diagnostics.error('analysis.extension-not-used', '`define machine` is `state-machines` extension vocabulary — add `use state-machines` to the story header.', decl.span);
|
|
797
|
+
}
|
|
798
|
+
const name = decl.name.join(' ');
|
|
799
|
+
if (this.machineNames.has(name)) {
|
|
800
|
+
this.diagnostics.error('analysis.duplicate-machine', `A machine named \`${name}\` already exists.`, decl.span);
|
|
801
|
+
}
|
|
802
|
+
this.machineNames.add(name);
|
|
803
|
+
const stateNames = new Set(decl.states.map((s) => s.name));
|
|
804
|
+
if (decl.states.length === 0) {
|
|
805
|
+
this.diagnostics.error('analysis.machine-states', `Machine \`${name}\` declares no states.`, decl.span);
|
|
806
|
+
}
|
|
807
|
+
if (decl.initialState === null) {
|
|
808
|
+
this.diagnostics.error('analysis.machine-starts', `Machine \`${name}\` needs a \`starts <state>\` line.`, decl.span);
|
|
809
|
+
}
|
|
810
|
+
else if (!stateNames.has(decl.initialState)) {
|
|
811
|
+
this.diagnostics.error('analysis.machine-starts', `\`starts ${decl.initialState}\` names no declared state of \`${name}\`${this.suggestText(decl.initialState, [...stateNames])}.`, decl.span);
|
|
812
|
+
}
|
|
813
|
+
const roles = new Map();
|
|
814
|
+
for (const role of decl.roles) {
|
|
815
|
+
if (roles.has(role.name)) {
|
|
816
|
+
this.diagnostics.error('analysis.machine-role', `Role \`${role.name}\` is declared twice on \`${name}\`.`, role.span);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const entity = this.resolveEntityId(role.entity);
|
|
820
|
+
if (entity !== null)
|
|
821
|
+
roles.set(role.name, entity);
|
|
822
|
+
}
|
|
823
|
+
const buildTransition = (t) => {
|
|
824
|
+
if (!stateNames.has(t.target)) {
|
|
825
|
+
this.diagnostics.error('analysis.machine-target', `\`${t.target}\` names no declared state of \`${name}\`${this.suggestText(t.target, [...stateNames])}.`, t.span);
|
|
826
|
+
}
|
|
827
|
+
let trigger;
|
|
828
|
+
switch (t.trigger.kind) {
|
|
829
|
+
case 'event':
|
|
830
|
+
trigger = { kind: 'event', event: t.trigger.event };
|
|
831
|
+
break;
|
|
832
|
+
case 'condition':
|
|
833
|
+
trigger = { kind: 'condition', condition: this.resolveCondition(t.trigger.condition, STORY_SCOPE) };
|
|
834
|
+
break;
|
|
835
|
+
case 'word': {
|
|
836
|
+
// Vocabulary-free parse: a declared condition or story state is a
|
|
837
|
+
// condition trigger; anything else is an action gerund.
|
|
838
|
+
if (this.conditionNames.has(t.trigger.word) || this.storyStates.includes(t.trigger.word)) {
|
|
839
|
+
trigger = {
|
|
840
|
+
kind: 'condition',
|
|
841
|
+
condition: this.resolveCondition({ kind: 'condition-ref', name: t.trigger.word, span: t.trigger.span }, STORY_SCOPE),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
trigger = { kind: 'action', action: t.trigger.word, target: null };
|
|
846
|
+
}
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
case 'action': {
|
|
850
|
+
let target = null;
|
|
851
|
+
if (t.trigger.target) {
|
|
852
|
+
const words = t.trigger.target.words.map((w) => w.toLowerCase());
|
|
853
|
+
if (words.length === 1 && roles.has(words[0])) {
|
|
854
|
+
target = `$${words[0]}`; // the platform binding convention
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
target = this.resolveEntityId(t.trigger.target);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
trigger = { kind: 'action', action: t.trigger.action, target };
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
trigger,
|
|
866
|
+
condition: t.condition ? this.resolveCondition(t.condition, STORY_SCOPE) : null,
|
|
867
|
+
target: t.target,
|
|
868
|
+
span: t.span,
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
return {
|
|
872
|
+
name,
|
|
873
|
+
roles: [...roles].map(([roleName, entity]) => ({ name: roleName, entity })),
|
|
874
|
+
initialState: decl.initialState ?? decl.states[0]?.name ?? '',
|
|
875
|
+
states: decl.states.map((s) => ({
|
|
876
|
+
name: s.name,
|
|
877
|
+
terminal: s.terminal,
|
|
878
|
+
transitions: s.transitions.map(buildTransition),
|
|
879
|
+
onEnter: s.onEnter.map((stmt) => this.resolveStatement(stmt, STORY_SCOPE)),
|
|
880
|
+
onExit: s.onExit.map((stmt) => this.resolveStatement(stmt, STORY_SCOPE)),
|
|
881
|
+
span: s.span,
|
|
882
|
+
})),
|
|
883
|
+
span: decl.span,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
310
886
|
/** Resolve a `when <owner> becomes <state>` step anchor (ratchet D10). */
|
|
311
887
|
resolveStepAnchor(step) {
|
|
312
888
|
if (step.timing !== 'becomes' || !step.owner || !step.state)
|
|
@@ -377,7 +953,7 @@ class Analyzer {
|
|
|
377
953
|
if (clause.binding === 'every-turn')
|
|
378
954
|
continue;
|
|
379
955
|
let key = `${clause.clauseKind}|${clause.action}|${clause.binding}|${clause.role ?? ''}`;
|
|
380
|
-
if (
|
|
956
|
+
if (catalog_js_1.EVENT_VERBS.has(clause.action)) {
|
|
381
957
|
key += `|${clause.condition ? conditionFingerprint(clause.condition) : ''}`;
|
|
382
958
|
}
|
|
383
959
|
const first = seen.get(key);
|
|
@@ -446,6 +1022,16 @@ class Analyzer {
|
|
|
446
1022
|
this.storyStates = this.ast.header.states.map((s) => s.name);
|
|
447
1023
|
for (const s of this.ast.header.scores)
|
|
448
1024
|
this.collectScore(s.name, s.worth, s.span, null);
|
|
1025
|
+
// Header `on every turn` clause bodies host inline phrase prose like
|
|
1026
|
+
// every other body context (platform-issue-sweep Phase 8 #14): story-
|
|
1027
|
+
// owned, so ownerless/bare-key scope — the same registration the five
|
|
1028
|
+
// pre-existing contexts use.
|
|
1029
|
+
for (const clause of this.ast.header.onClauses)
|
|
1030
|
+
this.collectInlineTexts(clause.body);
|
|
1031
|
+
// `use phrasebook` lines (ADR-250 D2/D3) — header position puts every
|
|
1032
|
+
// used book ahead of body-declared books in the arbitration order.
|
|
1033
|
+
for (const use of this.ast.header.usePhrasebooks)
|
|
1034
|
+
this.collectUsePhrasebook(use);
|
|
449
1035
|
}
|
|
450
1036
|
for (const decl of this.ast.declarations) {
|
|
451
1037
|
if (decl.kind === 'create')
|
|
@@ -466,6 +1052,50 @@ class Analyzer {
|
|
|
466
1052
|
this.collectPhrasesBlock(decl);
|
|
467
1053
|
else if (decl.kind === 'define-phrase')
|
|
468
1054
|
this.collectPhraseDecl(decl);
|
|
1055
|
+
else if (decl.kind === 'define-phrasebook')
|
|
1056
|
+
this.collectPhrasebook(decl);
|
|
1057
|
+
else if (decl.kind === 'override-message')
|
|
1058
|
+
this.collectOverrideMessage(decl);
|
|
1059
|
+
else if (decl.kind === 'override-messages')
|
|
1060
|
+
this.collectOverrideMessages(decl);
|
|
1061
|
+
else if (decl.kind === 'import') {
|
|
1062
|
+
// The compile host resolves imports before analysis (splicing the
|
|
1063
|
+
// fragment's declarations at this position); one surviving here means
|
|
1064
|
+
// no resolver ran — direct parse/analyze callers included.
|
|
1065
|
+
this.diagnostics.error('analysis.import-unresolved', `\`import "${decl.path}"\` was not resolved — compile with an \`importResolver\` host hook.`, decl.span);
|
|
1066
|
+
}
|
|
1067
|
+
else if (decl.kind === 'define-asset') {
|
|
1068
|
+
// ADR-216: declared media assets — DATA references, one namespace.
|
|
1069
|
+
if (this.assets.has(decl.name)) {
|
|
1070
|
+
this.diagnostics.error('analysis.duplicate-asset', `An asset named \`${decl.name}\` already exists.`, decl.span);
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
this.assets.set(decl.name, { kind: decl.assetKind, path: decl.path, span: decl.span });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
else if (decl.kind === 'define-family-channel') {
|
|
1077
|
+
// ADR-241 D2: named family channels, per-family namespace.
|
|
1078
|
+
const family = this.familyChannels[decl.family];
|
|
1079
|
+
if (family.has(decl.name)) {
|
|
1080
|
+
this.diagnostics.error('analysis.duplicate-channel', `An ${decl.family === 'ambient' ? 'ambient bed' : 'image layer'} named \`${decl.name}\` is already declared.`, decl.span);
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
family.set(decl.name, decl.span);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
else if (decl.kind === 'define-pronouns') {
|
|
1087
|
+
// ADR-242 D7: one namespace beside the standard four — shadowing a
|
|
1088
|
+
// standard word and redefining a set are each errors, never a merge.
|
|
1089
|
+
if (catalog_js_1.PRONOUN_WORDS.has(decl.name)) {
|
|
1090
|
+
this.diagnostics.error('analysis.pronoun-set-shadows', `\`${decl.name}\` is a standard pronoun set — \`define pronouns\` names a new set; pick another name.`, decl.span);
|
|
1091
|
+
}
|
|
1092
|
+
else if (this.pronounSetDecls.has(decl.name)) {
|
|
1093
|
+
this.diagnostics.error('analysis.duplicate-pronoun-set', `A pronoun set named \`${decl.name}\` is already defined.`, decl.span);
|
|
1094
|
+
}
|
|
1095
|
+
else {
|
|
1096
|
+
this.pronounSetDecls.set(decl.name, decl);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
469
1099
|
else if (decl.kind === 'define-trait') {
|
|
470
1100
|
this.traitNames.add(decl.name);
|
|
471
1101
|
for (const field of decl.data) {
|
|
@@ -512,6 +1142,15 @@ class Analyzer {
|
|
|
512
1142
|
for (const step of decl.steps)
|
|
513
1143
|
this.collectInlineTexts(step.body);
|
|
514
1144
|
}
|
|
1145
|
+
else if (decl.kind === 'define-machine') {
|
|
1146
|
+
// Machine state bodies (`on enter` / `on exit`) host inline phrase
|
|
1147
|
+
// prose like every other body context (platform-issue-sweep Phase 8
|
|
1148
|
+
// #14) — ownerless/bare-key scope, matching the header clauses.
|
|
1149
|
+
for (const state of decl.states) {
|
|
1150
|
+
this.collectInlineTexts(state.onEnter);
|
|
1151
|
+
this.collectInlineTexts(state.onExit);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
515
1154
|
}
|
|
516
1155
|
// Trait-declared states reach every composer (ratchet D8): merge each
|
|
517
1156
|
// trait's state set into the composing entity's, in composition order.
|
|
@@ -613,6 +1252,37 @@ class Analyzer {
|
|
|
613
1252
|
for (const clause of e.decl.onClauses)
|
|
614
1253
|
this.collectInlineTexts(clause.body, e.id);
|
|
615
1254
|
}
|
|
1255
|
+
// Topic-table row bodies are entity-owned (ADR-239): their inline
|
|
1256
|
+
// phrases register owner-scoped like clause bodies. Owner resolution
|
|
1257
|
+
// here is silent — an unresolved owner is pass 2's error (applyTopics).
|
|
1258
|
+
for (const decl of this.ast.declarations) {
|
|
1259
|
+
if (decl.kind !== 'define-topics')
|
|
1260
|
+
continue;
|
|
1261
|
+
const owner = this.findEntitySilent(decl.owner);
|
|
1262
|
+
if (!owner)
|
|
1263
|
+
continue;
|
|
1264
|
+
for (const row of decl.rows)
|
|
1265
|
+
this.collectInlineTexts(row.body, owner.id);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Resolve a name to an entity symbol with resolveEntityId's exact
|
|
1270
|
+
* precedence (exact name → alias → unique in-order subset) but NO
|
|
1271
|
+
* diagnostics — for pass-1 uses where pass 2 will report the miss.
|
|
1272
|
+
*/
|
|
1273
|
+
findEntitySilent(ref) {
|
|
1274
|
+
const lower = ref.words.join(' ').toLowerCase();
|
|
1275
|
+
const exact = this.entities.filter((e) => e.nameLower === lower);
|
|
1276
|
+
if (exact.length === 1)
|
|
1277
|
+
return exact[0];
|
|
1278
|
+
const byAlias = this.entities.filter((e) => e.aka.includes(lower));
|
|
1279
|
+
if (byAlias.length === 1)
|
|
1280
|
+
return byAlias[0];
|
|
1281
|
+
const refWords = ref.words.map((w) => w.toLowerCase());
|
|
1282
|
+
const subset = this.entities.filter((e) => isInOrderSubset(refWords, e.nameWords));
|
|
1283
|
+
if (subset.length === 1)
|
|
1284
|
+
return subset[0];
|
|
1285
|
+
return null;
|
|
616
1286
|
}
|
|
617
1287
|
/**
|
|
618
1288
|
* Declare-and-emit sugar (§2.6): a `phrase <key>` statement carrying an
|
|
@@ -667,7 +1337,7 @@ class Analyzer {
|
|
|
667
1337
|
this.diagnostics.error('analysis.boolean-state', `\`${s.name}\` is a boolean literal, not a state — booleans are not part of Chord at any scope (given 8). Name what ${ownerDesc} IS in that condition.`, s.span);
|
|
668
1338
|
}
|
|
669
1339
|
}
|
|
670
|
-
for (const { pair, trait } of
|
|
1340
|
+
for (const { pair, trait } of catalog_js_1.PLATFORM_STATE_PAIRS) {
|
|
671
1341
|
if (names.includes(pair[0]) && names.includes(pair[1])) {
|
|
672
1342
|
const at = states.find((s) => s.name === pair[1]) ?? states[0];
|
|
673
1343
|
this.diagnostics.error('analysis.shadow-state', `\`${pair[0]}\`/\`${pair[1]}\` reproduces a platform-owned pair — compose \`${trait}\` and read the state live (\`is ${pair[0]}\`) instead of storing it.`, at.span);
|
|
@@ -752,6 +1422,121 @@ class Analyzer {
|
|
|
752
1422
|
for (const s of decl.scores)
|
|
753
1423
|
this.collectScore(s.name, s.worth, s.span, id);
|
|
754
1424
|
}
|
|
1425
|
+
/** Construct kinds already reported by the scoring gate (one each). */
|
|
1426
|
+
scoringGateReported = new Set();
|
|
1427
|
+
/**
|
|
1428
|
+
* Report the `use scoring` gate for one construct kind (ADR-261 D4).
|
|
1429
|
+
*
|
|
1430
|
+
* A gated construct must never be silently dead, so this is an error rather
|
|
1431
|
+
* than a warning, backstopped by a `LoadError` in the story-loader for rogue
|
|
1432
|
+
* IR — the two-layer shape ADR-215 uses for `define machine`.
|
|
1433
|
+
*
|
|
1434
|
+
* The third gated construct, a `rank` rung, cannot reach this check: the
|
|
1435
|
+
* ladder is structurally inside the `use scoring` body, so a stray rung is
|
|
1436
|
+
* `parse.rank-outside-scoring` at parse time — an earlier and more precise
|
|
1437
|
+
* diagnostic. The loader's backstop covers the rogue-IR form.
|
|
1438
|
+
*/
|
|
1439
|
+
reportScoringGate(kind, span) {
|
|
1440
|
+
if (this.scoringGateReported.has(kind))
|
|
1441
|
+
return;
|
|
1442
|
+
this.scoringGateReported.add(kind);
|
|
1443
|
+
const what = kind === 'score'
|
|
1444
|
+
? 'A `score … worth N` line needs'
|
|
1445
|
+
: 'An `award` statement needs';
|
|
1446
|
+
this.diagnostics.error('analysis.scoring-needs-use', `${what} \`use scoring\` in the story header — without it the game has no score at all, and SCORE says so.`, span);
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Build the `use scoring` rank ladder (ADR-261 D2/D5).
|
|
1450
|
+
*
|
|
1451
|
+
* Rungs may be written in any order and are sorted ascending here, so the
|
|
1452
|
+
* loader and the ledger both receive a sorted ladder. Three gates:
|
|
1453
|
+
*
|
|
1454
|
+
* - **duplicate threshold** — silently keeping one rung would make the
|
|
1455
|
+
* resolved rank depend on array order (ADR-260 D2);
|
|
1456
|
+
* - **duplicate id** — two names that kebab-case alike collide in
|
|
1457
|
+
* `if.event.band_crossed`'s payload, which is keyed on the id;
|
|
1458
|
+
* - **rung above max** — an unreachable rank. This check is sound for
|
|
1459
|
+
* Chord *specifically* because Chord has no statement that changes
|
|
1460
|
+
* maxScore at runtime, so the sum of declared `worth` is the whole
|
|
1461
|
+
* ceiling. A TypeScript story calling `setMaxScore` mid-game (as dungeo
|
|
1462
|
+
* does) has no compile step and is unaffected, by design.
|
|
1463
|
+
*/
|
|
1464
|
+
buildRanks() {
|
|
1465
|
+
const declared = this.ast.header?.ranks ?? [];
|
|
1466
|
+
if (declared.length === 0)
|
|
1467
|
+
return [];
|
|
1468
|
+
const maxScore = this.scoreDecls.reduce((sum, s) => sum + s.worth, 0);
|
|
1469
|
+
const byThreshold = new Map();
|
|
1470
|
+
const byId = new Map();
|
|
1471
|
+
const ranks = [];
|
|
1472
|
+
for (const rung of declared) {
|
|
1473
|
+
const id = kebabId(rung.name);
|
|
1474
|
+
if (!id) {
|
|
1475
|
+
this.diagnostics.error('analysis.rank-id-empty', `Rank name "${rung.name}" yields no id — a rank name needs at least one letter or digit.`, rung.span);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
const clashingThreshold = byThreshold.get(rung.threshold);
|
|
1479
|
+
if (clashingThreshold !== undefined) {
|
|
1480
|
+
this.diagnostics.error('analysis.duplicate-rank-threshold', `Two rungs share the threshold ${rung.threshold} ("${clashingThreshold}" and "${rung.name}") — which rank applies would depend on source order.`, rung.span);
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
const clashingId = byId.get(id);
|
|
1484
|
+
if (clashingId !== undefined) {
|
|
1485
|
+
this.diagnostics.error('analysis.duplicate-rank-id', `Rank names "${clashingId}" and "${rung.name}" both reduce to the id \`${id}\` — a promotion event could not tell them apart.`, rung.span);
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (maxScore > 0 && rung.threshold > maxScore) {
|
|
1489
|
+
this.diagnostics.error('analysis.rank-above-max', `Rank "${rung.name}" sits at ${rung.threshold}, above the ${maxScore} points this story declares — no player could reach it.`, rung.span);
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
byThreshold.set(rung.threshold, rung.name);
|
|
1493
|
+
byId.set(id, rung.name);
|
|
1494
|
+
ranks.push({
|
|
1495
|
+
id,
|
|
1496
|
+
name: rung.name,
|
|
1497
|
+
threshold: rung.threshold,
|
|
1498
|
+
...(rung.phraseKey !== undefined ? { phraseKey: rung.phraseKey } : {}),
|
|
1499
|
+
span: rung.span,
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
return ranks.sort((a, b) => a.threshold - b.threshold);
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Lower the `use hunger` body (ADR-263 D1): dedup band thresholds, sort
|
|
1506
|
+
* ascending. `grows`/`fatal` pass through for the loader's daemon and
|
|
1507
|
+
* death-trigger lowering.
|
|
1508
|
+
*/
|
|
1509
|
+
buildHunger() {
|
|
1510
|
+
const decl = this.ast.header?.hunger;
|
|
1511
|
+
if (!decl)
|
|
1512
|
+
return undefined;
|
|
1513
|
+
const seen = new Set();
|
|
1514
|
+
const rungs = [];
|
|
1515
|
+
for (const rung of decl.rungs) {
|
|
1516
|
+
if (!rung.band) {
|
|
1517
|
+
this.diagnostics.error('analysis.meter-band-empty', 'A hunger band needs a name.', rung.span);
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
if (seen.has(rung.threshold)) {
|
|
1521
|
+
this.diagnostics.error('analysis.duplicate-hunger-threshold', `Two hunger bands share threshold ${rung.threshold} — the resolved band would depend on order.`, rung.span);
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
seen.add(rung.threshold);
|
|
1525
|
+
rungs.push({
|
|
1526
|
+
id: rung.band,
|
|
1527
|
+
threshold: rung.threshold,
|
|
1528
|
+
...(rung.phraseKey !== undefined ? { phraseKey: rung.phraseKey } : {}),
|
|
1529
|
+
span: rung.span,
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
rungs.sort((a, b) => a.threshold - b.threshold);
|
|
1533
|
+
return {
|
|
1534
|
+
...(decl.grows !== undefined ? { grows: decl.grows } : {}),
|
|
1535
|
+
...(decl.fatal !== undefined ? { fatal: decl.fatal } : {}),
|
|
1536
|
+
rungs,
|
|
1537
|
+
span: decl.span,
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
755
1540
|
/** Register an owner-attached score (ratchet D12) under its qualified id. */
|
|
756
1541
|
collectScore(name, worth, span, ownerKey) {
|
|
757
1542
|
const qualified = ownerKey ? `${ownerKey}.${name}` : name;
|
|
@@ -779,9 +1564,111 @@ class Analyzer {
|
|
|
779
1564
|
span: decl.span,
|
|
780
1565
|
});
|
|
781
1566
|
}
|
|
1567
|
+
/** ADR-255: `override message <alias>` — full phrase body under an ACL alias. */
|
|
1568
|
+
collectOverrideMessage(decl) {
|
|
1569
|
+
this.registerMessageOverride(DEFAULT_LOCALE, decl.alias, decl.span, {
|
|
1570
|
+
strategy: decl.strategy ?? null,
|
|
1571
|
+
...(decl.verbatim ? { verbatim: true } : {}),
|
|
1572
|
+
variants: decl.variants.map((v) => this.variantOf(v)),
|
|
1573
|
+
span: decl.span,
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
/** ADR-255: `override messages <locale>` — flat `alias: text` entries. */
|
|
1577
|
+
collectOverrideMessages(decl) {
|
|
1578
|
+
for (const entry of decl.entries) {
|
|
1579
|
+
this.registerMessageOverride(decl.locale, entry.key, entry.span, {
|
|
1580
|
+
strategy: null,
|
|
1581
|
+
variants: [this.variantOf(entry.value)],
|
|
1582
|
+
span: entry.span,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* ADR-255 D4: register a message override, gating the alias against the
|
|
1588
|
+
* ACL catalog (`analysis.unknown-message-alias`, mirroring `unknown-channel`)
|
|
1589
|
+
* and rejecting a duplicate. The alias — never a dotted platform id — is
|
|
1590
|
+
* resolved to `if.action.*` loader-side (Interface Contract 3).
|
|
1591
|
+
*/
|
|
1592
|
+
registerMessageOverride(locale, alias, span, phrase) {
|
|
1593
|
+
if (!alias)
|
|
1594
|
+
return; // header parse error already reported
|
|
1595
|
+
if (!catalog_js_1.MESSAGE_OVERRIDE_ALIASES.has(alias)) {
|
|
1596
|
+
this.diagnostics.error('analysis.unknown-message-alias', `\`${alias}\` is not a standard-action message alias${this.suggestText(alias, [...catalog_js_1.MESSAGE_OVERRIDE_ALIASES])}.`, span);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
let table = this.messageOverrides.get(locale);
|
|
1600
|
+
if (!table) {
|
|
1601
|
+
table = new Map();
|
|
1602
|
+
this.messageOverrides.set(locale, table);
|
|
1603
|
+
}
|
|
1604
|
+
if (table.has(alias)) {
|
|
1605
|
+
this.diagnostics.error('analysis.duplicate-message-override', `Message override \`${alias}\` is declared twice in ${locale}.`, span);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
table.set(alias, phrase);
|
|
1609
|
+
}
|
|
782
1610
|
variantOf(value) {
|
|
783
1611
|
return { text: value.text, markers: value.markers.map((m) => m.content) };
|
|
784
1612
|
}
|
|
1613
|
+
/** Duplicate-name gate shared by `define phrasebook` and `use phrasebook`. */
|
|
1614
|
+
registerPhrasebookName(name, span) {
|
|
1615
|
+
const first = this.phrasebookNames.get(name);
|
|
1616
|
+
if (first) {
|
|
1617
|
+
this.diagnostics.error('analysis.duplicate-phrasebook', `A phrasebook named \`${name}\` is already declared at line ${first.line}.`, span);
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
this.phrasebookNames.set(name, span);
|
|
1621
|
+
return true;
|
|
1622
|
+
}
|
|
1623
|
+
/** Book coverage bookkeeping: key → covered by a default (always) book? */
|
|
1624
|
+
recordBookKey(key, isAlways) {
|
|
1625
|
+
this.bookKeys.set(key, (this.bookKeys.get(key) ?? false) || isAlways);
|
|
1626
|
+
}
|
|
1627
|
+
/** `use phrasebook <name> [while <cond>]` (ADR-250 D2/D3) — pass 1. */
|
|
1628
|
+
collectUsePhrasebook(use) {
|
|
1629
|
+
const manifest = phrasebooks_js_1.PHRASEBOOK_REGISTRY.get(use.name);
|
|
1630
|
+
if (!manifest) {
|
|
1631
|
+
this.diagnostics.error('analysis.unknown-phrasebook', `\`use phrasebook ${use.name}\` names no registered phrasebook${this.suggestText(use.name, [...phrasebooks_js_1.PHRASEBOOK_REGISTRY.keys()])}.`, use.span);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (!this.registerPhrasebookName(use.name, use.span))
|
|
1635
|
+
return;
|
|
1636
|
+
this.phrasebookDecls.push({ name: use.name, source: 'use', condition: use.condition, span: use.span });
|
|
1637
|
+
for (const key of manifest.keys)
|
|
1638
|
+
this.recordBookKey(key, use.condition === null);
|
|
1639
|
+
}
|
|
1640
|
+
/** `define phrasebook` (ADR-250 D1/D3) — pass 1: entry gates + coverage. */
|
|
1641
|
+
collectPhrasebook(decl) {
|
|
1642
|
+
if (!decl.name)
|
|
1643
|
+
return; // header parse error already reported
|
|
1644
|
+
if (!this.registerPhrasebookName(decl.name, decl.span))
|
|
1645
|
+
return;
|
|
1646
|
+
const entries = {};
|
|
1647
|
+
for (const entry of decl.entries) {
|
|
1648
|
+
if (entry.condition) {
|
|
1649
|
+
this.diagnostics.error('analysis.phrasebook-entry-gate', `Entries carry no \`while\` — the book's own predicate is the gate. Move the condition to \`define phrasebook ${decl.name} while …\`, or split the entry into a second book.`, entry.span);
|
|
1650
|
+
}
|
|
1651
|
+
if (entry.key.includes('.')) {
|
|
1652
|
+
this.diagnostics.error('analysis.phrasebook-dotted-key', `\`${entry.key}\` is a platform message ID — phrasebooks voice the story's own keys. To override a platform message, use a story-level \`define phrase ${entry.key}\`.`, entry.span);
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
if (entry.key === 'br' || RESERVED_CHANNEL_KEYS.has(entry.key)) {
|
|
1656
|
+
this.diagnostics.error('analysis.phrasebook-reserved-key', `\`${entry.key}\` is reserved — pick another entry key.`, entry.span);
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
if (entries[entry.key]) {
|
|
1660
|
+
this.diagnostics.error('analysis.phrasebook-duplicate-key', `\`${entry.key}\` is declared twice in phrasebook \`${decl.name}\` — competing texts live in different books.`, entry.span);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
entries[entry.key] = {
|
|
1664
|
+
strategy: entry.strategy ?? null,
|
|
1665
|
+
variants: entry.variants.map((v) => this.variantOf(v)),
|
|
1666
|
+
span: entry.span,
|
|
1667
|
+
};
|
|
1668
|
+
this.recordBookKey(entry.key, decl.condition === null);
|
|
1669
|
+
}
|
|
1670
|
+
this.phrasebookDecls.push({ name: decl.name, source: 'define', condition: decl.condition, entries, span: decl.span });
|
|
1671
|
+
}
|
|
785
1672
|
registerPhrase(locale, key, phrase) {
|
|
786
1673
|
if (key === 'br') {
|
|
787
1674
|
// `{br}` is the built-in hard-line-break marker (grammar log 2026-07-10).
|
|
@@ -816,7 +1703,16 @@ class Analyzer {
|
|
|
816
1703
|
for (const comp of decl.compositions) {
|
|
817
1704
|
const built = {
|
|
818
1705
|
name: comp.words.join(' ').toLowerCase(),
|
|
819
|
-
config: comp.config.map((c) => ({
|
|
1706
|
+
config: comp.config.map((c) => ({
|
|
1707
|
+
key: c.key.join(' '),
|
|
1708
|
+
value: c.value,
|
|
1709
|
+
valueKind: c.valueKind,
|
|
1710
|
+
// `[ … ]` list entries resolve to entity IDs here (ADR-215) —
|
|
1711
|
+
// unresolved names report through the standard unknown-entity gate.
|
|
1712
|
+
...(c.valueKind === 'list'
|
|
1713
|
+
? { values: (c.listValues ?? []).map((ref) => this.resolveEntityId(ref) ?? '').filter((id) => id !== '') }
|
|
1714
|
+
: {}),
|
|
1715
|
+
})),
|
|
820
1716
|
condition: comp.condition ? this.resolveCondition(comp.condition, entityScope(sym ?? null)) : null,
|
|
821
1717
|
span: comp.span,
|
|
822
1718
|
};
|
|
@@ -824,6 +1720,128 @@ class Analyzer {
|
|
|
824
1720
|
kinds.push(built);
|
|
825
1721
|
else
|
|
826
1722
|
traits.push(built);
|
|
1723
|
+
// ADR-215: extension vocabulary is admitted only when its `use` is
|
|
1724
|
+
// declared (core manifests — npc — are always admitted), and its
|
|
1725
|
+
// `with`-fields are the manifest's closed, typed set — unknown keys
|
|
1726
|
+
// and mistyped values are compile errors, never a silent drop at the
|
|
1727
|
+
// loader. `[ … ]` list values exist only as manifest list fields.
|
|
1728
|
+
if (!comp.article) {
|
|
1729
|
+
const contributed = (0, index_js_1.manifestForAdjective)(built.name);
|
|
1730
|
+
if (!contributed) {
|
|
1731
|
+
for (const cfg of comp.config) {
|
|
1732
|
+
if (cfg.valueKind === 'list') {
|
|
1733
|
+
this.diagnostics.error('analysis.config-list-host', `\`[ … ]\` list values belong to extension fields that declare them (e.g. \`patrol route [ … ]\`) — \`${built.name}\` has none.`, cfg.span);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (contributed) {
|
|
1738
|
+
if (!contributed.manifest.core && !this.usedExtensions.has(contributed.manifest.name)) {
|
|
1739
|
+
this.diagnostics.error('analysis.extension-not-used', `\`${built.name}\` is \`${contributed.manifest.name}\` extension vocabulary — add \`use ${contributed.manifest.name}\` to the story header.`, comp.span);
|
|
1740
|
+
}
|
|
1741
|
+
else {
|
|
1742
|
+
for (const cfg of comp.config) {
|
|
1743
|
+
const key = cfg.key.join(' ');
|
|
1744
|
+
const field = contributed.adjective.fields.find((f) => f.key === key);
|
|
1745
|
+
if (!field) {
|
|
1746
|
+
const known = contributed.adjective.fields.map((f) => f.key).join(', ');
|
|
1747
|
+
this.diagnostics.error('analysis.extension-config-key', `\`${key}\` is not a \`${built.name}\` field — known fields: ${known}.`, cfg.span);
|
|
1748
|
+
}
|
|
1749
|
+
else if (field.valueKind !== cfg.valueKind) {
|
|
1750
|
+
this.diagnostics.error('analysis.extension-config-value', `\`${key}\` takes a ${field.valueKind} value, not ${cfg.valueKind === 'name' ? 'an entity name' : `a ${cfg.valueKind}`}.`, cfg.span);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
// ADR-231 D5a pairing gate: each `starts <state>` initializer requires
|
|
1758
|
+
// its paired trait composed on the same entity (`starts locked` needs
|
|
1759
|
+
// `lockable`, `starts closed`/`open` need `openable`, `starts off`/`on`
|
|
1760
|
+
// need `switchable`). Table-driven — STARTS_STATE_PAIRINGS is the one
|
|
1761
|
+
// place future stateful traits extend. Mismatch = load-time error, never
|
|
1762
|
+
// a silent no-op.
|
|
1763
|
+
const startsStates = [];
|
|
1764
|
+
for (const s of decl.startsStates) {
|
|
1765
|
+
const requiredTrait = catalog_js_1.STARTS_STATE_PAIRINGS.get(s.state);
|
|
1766
|
+
if (!requiredTrait)
|
|
1767
|
+
continue; // parser already rejected the word
|
|
1768
|
+
if (!traits.some((t) => t.name === requiredTrait)) {
|
|
1769
|
+
this.diagnostics.error('analysis.starts-state-pairing', `\`starts ${s.state}\` requires \`${requiredTrait}\` composed on this entity.`, s.span);
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
startsStates.push(s.state);
|
|
1773
|
+
}
|
|
1774
|
+
// ADR-242 D1: `proper` is the first kind-scoped trait adjective —
|
|
1775
|
+
// person-only and unconditional (identity is not turn state). Both
|
|
1776
|
+
// gates are analyzer diagnostics so the author reads the specific
|
|
1777
|
+
// reason, not the loader's generic conditional-composition error.
|
|
1778
|
+
const isPerson = kinds.some((k) => k.name === 'person');
|
|
1779
|
+
for (const comp of decl.compositions) {
|
|
1780
|
+
if (comp.article || comp.words.join(' ').toLowerCase() !== 'proper')
|
|
1781
|
+
continue;
|
|
1782
|
+
if (!isPerson) {
|
|
1783
|
+
this.diagnostics.error('analysis.proper-person-only', `\`proper\` composes only on a person (\`a person, proper\`) — \`${decl.name.words.join(' ')}\` is not a person.`, comp.span);
|
|
1784
|
+
}
|
|
1785
|
+
if (comp.condition) {
|
|
1786
|
+
this.diagnostics.error('analysis.proper-conditional', 'Identity is not conditional — `proper while …` is not supported; a name is proper or it is not.', comp.span);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
// ADR-242 D5: `pronouns <word>` — person-only, at most one line, and
|
|
1790
|
+
// the word resolves against the standard four or a `define pronouns`
|
|
1791
|
+
// set (never guessed; nearest-match suggestion on a miss, ruled Q-2:
|
|
1792
|
+
// no default is injected when the line is absent).
|
|
1793
|
+
let pronouns;
|
|
1794
|
+
if (decl.pronouns.length > 0) {
|
|
1795
|
+
if (!isPerson) {
|
|
1796
|
+
this.diagnostics.error('analysis.pronouns-person-only', `\`pronouns\` is a person line — \`${decl.name.words.join(' ')}\` is not a person.`, decl.pronouns[0].span);
|
|
1797
|
+
}
|
|
1798
|
+
for (const extra of decl.pronouns.slice(1)) {
|
|
1799
|
+
this.diagnostics.error('analysis.pronouns-duplicate', 'This `create` block already has a `pronouns` line.', extra.span);
|
|
1800
|
+
}
|
|
1801
|
+
const word = decl.pronouns[0].word;
|
|
1802
|
+
if (catalog_js_1.PRONOUN_WORDS.has(word) || this.pronounSetDecls.has(word)) {
|
|
1803
|
+
if (isPerson)
|
|
1804
|
+
pronouns = word;
|
|
1805
|
+
}
|
|
1806
|
+
else {
|
|
1807
|
+
const known = [...catalog_js_1.PRONOUN_WORDS, ...this.pronounSetDecls.keys()];
|
|
1808
|
+
this.diagnostics.error('analysis.unknown-pronouns', `\`${word}\` is not a pronoun set — the standard sets are ${[...catalog_js_1.PRONOUN_WORDS].map((w) => `\`${w}\``).join(', ')}, plus any \`define pronouns\` set${this.suggestText(word, known)}.`, decl.pronouns[0].span);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// Player-block composition (Gap-2 ruling, David 2026-07-18): the
|
|
1812
|
+
// player composes like any entity — but only `a person` is a legal
|
|
1813
|
+
// kind (a no-op; the player is already an actor), and NPC behavior
|
|
1814
|
+
// adjectives would hand the player to the NPC service. Both gate.
|
|
1815
|
+
if (isPlayer) {
|
|
1816
|
+
for (const kind of kinds) {
|
|
1817
|
+
if (kind.name !== 'person') {
|
|
1818
|
+
this.diagnostics.error('analysis.player-kind', `The player cannot be \`a ${kind.name}\` — only \`a person\` composes on the player block (as a no-op).`, kind.span);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
for (const trait of traits) {
|
|
1822
|
+
const contributed = (0, index_js_1.manifestForAdjective)(trait.name);
|
|
1823
|
+
if (contributed?.manifest.name === 'npc') {
|
|
1824
|
+
this.diagnostics.error('analysis.player-behavior', `\`${trait.name}\` is an NPC behavior — the player is not driven by the NPC service.`, trait.span);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
// ADR-234 D3: a door's location IS its room pair — the loader places
|
|
1829
|
+
// it in room1 per the platform convention; a placement line is a load
|
|
1830
|
+
// error (the region-placement gate is the direct precedent).
|
|
1831
|
+
if (kinds.some((k) => k.name === 'door') && decl.placement) {
|
|
1832
|
+
this.diagnostics.error('analysis.door-placement', `A door has no placement — its location IS its room pair (the loader places it in the first room of its \`through\` exit line). Remove this line.`, decl.placement.span);
|
|
1833
|
+
}
|
|
1834
|
+
// ADR-236 D1: a region's "location" IS its member list — placement
|
|
1835
|
+
// lines on a region block are a load error (mirror of ADR-234 D3's
|
|
1836
|
+
// door-placement gate).
|
|
1837
|
+
const isRegion = kinds.some((k) => k.name === 'region');
|
|
1838
|
+
if (isRegion && decl.placement) {
|
|
1839
|
+
this.diagnostics.error('analysis.region-placement', `A region has no location — its place IS its member list. Remove this line; membership is \`containing <rooms>\`.`, decl.placement.span);
|
|
1840
|
+
}
|
|
1841
|
+
// ADR-236 D2: `containing` is region membership — on any other block it
|
|
1842
|
+
// would be a silent no-op, so it is a load error, never a guess.
|
|
1843
|
+
if (!isRegion && decl.containing.length > 0) {
|
|
1844
|
+
this.diagnostics.error('analysis.region-containing-host', `\`containing\` declares region membership — \`${decl.name.words.join(' ')}\` is not a region. (Contents are placed with \`in\`/\`on\` lines on the contained entity.)`, decl.containing[0].span);
|
|
827
1845
|
}
|
|
828
1846
|
// Z1: `first time` prose compiles to RoomTrait.initialDescription —
|
|
829
1847
|
// only rooms carry that field, so any other kind is a load error
|
|
@@ -836,9 +1854,13 @@ class Analyzer {
|
|
|
836
1854
|
name: decl.name.words.join(' '),
|
|
837
1855
|
article: decl.name.article,
|
|
838
1856
|
aka: decl.aka,
|
|
1857
|
+
// Present only when declared and resolved (ruled Q-2: absent means
|
|
1858
|
+
// the platform's by-number fallback — and zero golden churn).
|
|
1859
|
+
...(pronouns !== undefined ? { pronouns } : {}),
|
|
839
1860
|
isPlayer,
|
|
840
1861
|
kinds,
|
|
841
1862
|
traits,
|
|
1863
|
+
startsStates,
|
|
842
1864
|
placement: decl.placement
|
|
843
1865
|
? {
|
|
844
1866
|
relation: decl.placement.relation,
|
|
@@ -848,7 +1870,18 @@ class Analyzer {
|
|
|
848
1870
|
: null,
|
|
849
1871
|
wears: decl.wears.map((w) => this.resolveEntityId(w) ?? '').filter((w) => w !== ''),
|
|
850
1872
|
carries: decl.carries.map((c) => this.resolveEntityId(c) ?? '').filter((c) => c !== ''),
|
|
851
|
-
|
|
1873
|
+
containing: decl.containing
|
|
1874
|
+
.map((m) => ({ id: this.resolveEntityId(m) ?? '', span: m.span }))
|
|
1875
|
+
.filter((m) => m.id !== ''),
|
|
1876
|
+
exits: decl.exits.map((e) => ({
|
|
1877
|
+
direction: e.direction,
|
|
1878
|
+
to: this.resolveEntityId(e.to) ?? '',
|
|
1879
|
+
// `through the <door>` (ADR-234 D1): resolved like any entity
|
|
1880
|
+
// reference — an unknown name is the standard unresolved-entity
|
|
1881
|
+
// error; '' marks it so checkDoors skips what is already reported.
|
|
1882
|
+
via: e.via ? (this.resolveEntityId(e.via) ?? '') : null,
|
|
1883
|
+
span: e.span,
|
|
1884
|
+
})),
|
|
852
1885
|
blockedExits: decl.blockedExits.map((b) => {
|
|
853
1886
|
this.requirePhrase(b.phraseKey, b.span);
|
|
854
1887
|
return {
|
|
@@ -860,6 +1893,14 @@ class Analyzer {
|
|
|
860
1893
|
}),
|
|
861
1894
|
deadlyExits: decl.deadlyExits.map((d) => {
|
|
862
1895
|
this.requirePhrase(d.phraseKey, d.span);
|
|
1896
|
+
// Compile gate (platform-issue-sweep Phase 8 #15d): the conditional
|
|
1897
|
+
// form is post-scope (mirror: role-bound trait clauses). It used to
|
|
1898
|
+
// fail only at LOAD (loader.ts throw — kept there as the defensive
|
|
1899
|
+
// backstop), which the harness's expect-fail-manifest convention
|
|
1900
|
+
// cannot pin; failing here makes it a compile diagnostic.
|
|
1901
|
+
if (d.condition !== null) {
|
|
1902
|
+
this.diagnostics.error('analysis.deadly-while-unsupported', '`is deadly while <condition>` is not wired yet — the conditional deadly exit is post-scope. Use an unconditional `is deadly:` or an `on going` clause with `kill the player when <condition>`.', d.span);
|
|
1903
|
+
}
|
|
863
1904
|
return {
|
|
864
1905
|
direction: d.direction,
|
|
865
1906
|
phraseKey: d.phraseKey,
|
|
@@ -878,6 +1919,8 @@ class Analyzer {
|
|
|
878
1919
|
descriptionKey: decl.description ? `${id}.description` : null,
|
|
879
1920
|
initialDescriptionKey: decl.initialDescription ? `${id}.initial-description` : null,
|
|
880
1921
|
onClauses: this.checkDuplicateClauses(decl.onClauses, decl.name.words.join(' ').toLowerCase()).map((c) => this.buildOnClause(c, entityScope(sym ?? null))),
|
|
1922
|
+
// Filled by applyTopics after every entity is built (ADR-239).
|
|
1923
|
+
topics: [],
|
|
881
1924
|
span: decl.span,
|
|
882
1925
|
};
|
|
883
1926
|
}
|
|
@@ -998,8 +2041,26 @@ class Analyzer {
|
|
|
998
2041
|
span: stmt.span,
|
|
999
2042
|
};
|
|
1000
2043
|
}
|
|
1001
|
-
case 'emit':
|
|
1002
|
-
|
|
2044
|
+
case 'emit': {
|
|
2045
|
+
const payload = stmt.payload.map((f) => this.resolveEmitField(f, scope));
|
|
2046
|
+
// ADR-253 D1: record the event's top-level payload field names so a
|
|
2047
|
+
// channel `return`ing an unknown field is caught (checkChannelReturns).
|
|
2048
|
+
const eventId = stmt.event.join(' ');
|
|
2049
|
+
let fields = this.emitFields.get(eventId);
|
|
2050
|
+
if (!fields) {
|
|
2051
|
+
fields = new Set();
|
|
2052
|
+
this.emitFields.set(eventId, fields);
|
|
2053
|
+
}
|
|
2054
|
+
for (const f of stmt.payload)
|
|
2055
|
+
fields.add(f.key.join(' '));
|
|
2056
|
+
return {
|
|
2057
|
+
kind: 'emit',
|
|
2058
|
+
event: eventId,
|
|
2059
|
+
...(payload.length > 0 ? { payload } : {}),
|
|
2060
|
+
stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope),
|
|
2061
|
+
span: stmt.span,
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
1003
2064
|
case 'set':
|
|
1004
2065
|
return {
|
|
1005
2066
|
kind: 'set',
|
|
@@ -1055,6 +2116,10 @@ class Analyzer {
|
|
|
1055
2116
|
span: stmt.span,
|
|
1056
2117
|
};
|
|
1057
2118
|
case 'award': {
|
|
2119
|
+
// ADR-261 D4: `award` is gated with `score` and `ranks`.
|
|
2120
|
+
if (!this.usedExtensions.has('scoring')) {
|
|
2121
|
+
this.reportScoringGate('award', stmt.span);
|
|
2122
|
+
}
|
|
1058
2123
|
// `award <score-name>` resolves owner-first (ratchet D12): the
|
|
1059
2124
|
// enclosing owner's qualified id, then the story-level bare name.
|
|
1060
2125
|
let expression = stmt.expression;
|
|
@@ -1098,6 +2163,8 @@ class Analyzer {
|
|
|
1098
2163
|
span: stmt.span,
|
|
1099
2164
|
};
|
|
1100
2165
|
}
|
|
2166
|
+
case 'media':
|
|
2167
|
+
return this.lowerMediaStatement(stmt, scope);
|
|
1101
2168
|
case 'select-on': {
|
|
1102
2169
|
const subject = this.resolveValue(stmt.subject, scope);
|
|
1103
2170
|
const stateOwner = this.stateOwnerOf(subject, scope);
|
|
@@ -1204,6 +2271,230 @@ class Analyzer {
|
|
|
1204
2271
|
return this.resolveMatch(expr.span, scope);
|
|
1205
2272
|
}
|
|
1206
2273
|
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Lower one media sugar statement (ADR-216) onto a payloaded `media-*`
|
|
2276
|
+
* emit — pure compile-time sugar, no runtime surface of its own. The IR
|
|
2277
|
+
* event id is the dotless Chord form (ADR-254/256); `@sharpee/story-loader`
|
|
2278
|
+
* translates it to the platform's dotted `media.*` id at the emit seam.
|
|
2279
|
+
* Asset references are typo-checked with a nearest-match suggestion; kind
|
|
2280
|
+
* mismatches gate (`play ambient` plays SOUND assets — an ambient loop
|
|
2281
|
+
* is a sound file).
|
|
2282
|
+
*/
|
|
2283
|
+
lowerMediaStatement(stmt, scope) {
|
|
2284
|
+
const stmtWhen = this.resolveStmtWhen(stmt.stmtWhen, scope);
|
|
2285
|
+
const fields = [];
|
|
2286
|
+
let event = '';
|
|
2287
|
+
const requireAsset = (expected) => {
|
|
2288
|
+
if (!stmt.asset)
|
|
2289
|
+
return; // the parser already reported
|
|
2290
|
+
const found = this.assets.get(stmt.asset);
|
|
2291
|
+
if (!found) {
|
|
2292
|
+
this.diagnostics.error('analysis.unknown-asset', `\`${stmt.asset}\` names no declared asset${this.suggestText(stmt.asset, [...this.assets.keys()])}. Declare it: \`define ${expected} ${stmt.asset} from "<file>"\`.`, stmt.span);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
if (found.kind !== expected) {
|
|
2296
|
+
this.diagnostics.error('analysis.asset-kind', `\`${stmt.asset}\` is a ${found.kind} asset — this statement needs a ${expected} asset.`, stmt.span);
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
fields.push({ key: 'src', value: { kind: 'literal', value: found.path, valueType: 'string' } });
|
|
2300
|
+
};
|
|
2301
|
+
switch (stmt.form) {
|
|
2302
|
+
case 'play-sound':
|
|
2303
|
+
event = 'media-sound-play';
|
|
2304
|
+
requireAsset('sound');
|
|
2305
|
+
break;
|
|
2306
|
+
case 'play-music':
|
|
2307
|
+
event = 'media-music-play';
|
|
2308
|
+
requireAsset('music');
|
|
2309
|
+
if (stmt.looping)
|
|
2310
|
+
fields.push({ key: 'loop', value: { kind: 'value', value: { kind: 'symbol', name: 'true' } } });
|
|
2311
|
+
break;
|
|
2312
|
+
case 'stop-music':
|
|
2313
|
+
event = 'media-music-stop';
|
|
2314
|
+
break;
|
|
2315
|
+
case 'play-ambient':
|
|
2316
|
+
event = 'media-ambient-play';
|
|
2317
|
+
requireAsset('sound');
|
|
2318
|
+
this.stampAmbientChannel(stmt, fields);
|
|
2319
|
+
break;
|
|
2320
|
+
case 'stop-ambient':
|
|
2321
|
+
event = 'media-ambient-stop';
|
|
2322
|
+
this.stampAmbientChannel(stmt, fields);
|
|
2323
|
+
break;
|
|
2324
|
+
case 'show-image':
|
|
2325
|
+
event = 'media-image-show';
|
|
2326
|
+
requireAsset('image');
|
|
2327
|
+
if (stmt.layer) {
|
|
2328
|
+
// ADR-241 D3: layers beyond the platform's pre-registered three
|
|
2329
|
+
// must be declared (`define layer <word>`) — never-guess.
|
|
2330
|
+
if (!IMPLIED_IMAGE_LAYERS.has(stmt.layer) && !this.familyChannels.layer.has(stmt.layer)) {
|
|
2331
|
+
this.diagnostics.error('analysis.unknown-channel', `\`${stmt.layer}\` names no declared image layer${this.suggestText(stmt.layer, [...IMPLIED_IMAGE_LAYERS, ...this.familyChannels.layer.keys()])}. Declare it: \`define layer ${stmt.layer}\`.`, stmt.span);
|
|
2332
|
+
}
|
|
2333
|
+
fields.push({ key: 'layer', value: { kind: 'literal', value: stmt.layer, valueType: 'string' } });
|
|
2334
|
+
}
|
|
2335
|
+
break;
|
|
2336
|
+
case 'hide-image':
|
|
2337
|
+
event = 'media-image-hide';
|
|
2338
|
+
break;
|
|
2339
|
+
case 'transition':
|
|
2340
|
+
event = 'media-transition';
|
|
2341
|
+
fields.push({ key: 'kind', value: { kind: 'literal', value: stmt.transitionKind ?? '', valueType: 'string' } });
|
|
2342
|
+
break;
|
|
2343
|
+
case 'clear':
|
|
2344
|
+
event = 'media-clear';
|
|
2345
|
+
break;
|
|
2346
|
+
}
|
|
2347
|
+
return { kind: 'emit', event, ...(fields.length > 0 ? { payload: fields } : {}), stmtWhen, span: stmt.span };
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* ADR-241 D3: resolve an ambient statement's channel word (the default
|
|
2351
|
+
* bed is `main` — Q-1), gate undeclared words (never-guess — Q-3), and
|
|
2352
|
+
* stamp `channel` onto the payload — the field stdlib's ambient
|
|
2353
|
+
* channels filter on. A bare use of the implied `main` bed records its
|
|
2354
|
+
* first use-site so the bed joins the channel manifest (D4).
|
|
2355
|
+
*/
|
|
2356
|
+
stampAmbientChannel(stmt, fields) {
|
|
2357
|
+
const word = stmt.channel ?? 'main';
|
|
2358
|
+
if (word === 'main') {
|
|
2359
|
+
if (!this.familyChannels.ambient.has('main'))
|
|
2360
|
+
this.impliedMainBedSpan ??= stmt.span;
|
|
2361
|
+
}
|
|
2362
|
+
else if (!this.familyChannels.ambient.has(word)) {
|
|
2363
|
+
this.diagnostics.error('analysis.unknown-channel', `\`${word}\` names no declared ambient bed${this.suggestText(word, ['main', ...this.familyChannels.ambient.keys()])}. Declare it: \`define ambient ${word}\`.`, stmt.span);
|
|
2364
|
+
}
|
|
2365
|
+
fields.push({ key: 'channel', value: { kind: 'literal', value: word, valueType: 'string' } });
|
|
2366
|
+
}
|
|
2367
|
+
/** Channel names seen (duplicate gate). */
|
|
2368
|
+
channelNames = new Set();
|
|
2369
|
+
/** Declared family channels (ADR-241 D2) by family: word → first declaration span. */
|
|
2370
|
+
familyChannels = {
|
|
2371
|
+
ambient: new Map(),
|
|
2372
|
+
layer: new Map(),
|
|
2373
|
+
};
|
|
2374
|
+
/** First use-site of the implied `main` ambient bed (ADR-241 D3), if any. */
|
|
2375
|
+
impliedMainBedSpan = null;
|
|
2376
|
+
/**
|
|
2377
|
+
* Build one `define channel` (ADR-216; spelling A): mode/from/take are
|
|
2378
|
+
* required, `gated by` must name a client capability flag (Chord
|
|
2379
|
+
* spelling, lowered to the platform's camelCase key). Re-declaring a
|
|
2380
|
+
* STANDARD channel id is legal platform behavior (story override);
|
|
2381
|
+
* duplicating a story channel is not.
|
|
2382
|
+
*/
|
|
2383
|
+
buildChannel(decl) {
|
|
2384
|
+
if (this.channelNames.has(decl.name)) {
|
|
2385
|
+
this.diagnostics.error('analysis.duplicate-channel', `A channel named \`${decl.name}\` already exists.`, decl.span);
|
|
2386
|
+
}
|
|
2387
|
+
this.channelNames.add(decl.name);
|
|
2388
|
+
if (decl.mode === null || !['replace', 'append', 'event'].includes(decl.mode)) {
|
|
2389
|
+
this.diagnostics.error('analysis.channel-mode', `Channel \`${decl.name}\` needs \`mode replace\`, \`mode append\`, or \`mode event\`${decl.mode ? ` — got \`${decl.mode}\`` : ''}.`, decl.span);
|
|
2390
|
+
}
|
|
2391
|
+
if (decl.returns === null || decl.fromEvent === null) {
|
|
2392
|
+
this.diagnostics.error('analysis.channel-return', `Channel \`${decl.name}\` needs a \`return <field | "text" | phrase <key>> from <event>\` line.`, decl.span);
|
|
2393
|
+
}
|
|
2394
|
+
let gatedBy = null;
|
|
2395
|
+
if (decl.gatedBy !== null) {
|
|
2396
|
+
if (!catalog_js_1.CLIENT_CAPABILITY_FLAGS.has(decl.gatedBy)) {
|
|
2397
|
+
this.diagnostics.error('analysis.unknown-capability', `\`${decl.gatedBy}\` is not a client capability flag${this.suggestText(decl.gatedBy, [...catalog_js_1.CLIENT_CAPABILITY_FLAGS])}.`, decl.span);
|
|
2398
|
+
}
|
|
2399
|
+
else {
|
|
2400
|
+
gatedBy = (0, catalog_js_1.capabilityKeyOf)(decl.gatedBy);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
return {
|
|
2404
|
+
name: decl.name,
|
|
2405
|
+
family: 'data',
|
|
2406
|
+
mode: (decl.mode ?? 'event'),
|
|
2407
|
+
gatedBy,
|
|
2408
|
+
fromEvent: decl.fromEvent ?? '',
|
|
2409
|
+
returns: decl.returns ?? { kind: 'field', field: '' },
|
|
2410
|
+
span: decl.span,
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* ADR-253 D1 field check: a channel returning a `field` (or a `text` template
|
|
2415
|
+
* with `(slot)` names) references event payload fields; error when the named
|
|
2416
|
+
* event emits a payload that lacks one. Runs as a post-pass so every `emit`
|
|
2417
|
+
* is collected first, regardless of declaration order. An event with no
|
|
2418
|
+
* collected payload (platform events, or one never emitted here) is skipped —
|
|
2419
|
+
* its field set is unknown, not empty. `phrase` returns own their slots
|
|
2420
|
+
* (the phrase system), so they are not cross-checked here.
|
|
2421
|
+
*/
|
|
2422
|
+
checkChannelReturns(channels) {
|
|
2423
|
+
for (const ch of channels) {
|
|
2424
|
+
if (ch.family !== 'data')
|
|
2425
|
+
continue;
|
|
2426
|
+
const emitted = this.emitFields.get(ch.fromEvent);
|
|
2427
|
+
if (!emitted)
|
|
2428
|
+
continue; // unknown field set — cannot check
|
|
2429
|
+
const required = ch.returns.kind === 'field'
|
|
2430
|
+
? [ch.returns.field]
|
|
2431
|
+
: ch.returns.kind === 'text'
|
|
2432
|
+
? [...ch.returns.text.matchAll(/\(\s*([^)]+?)\s*\)/g)].map((m) => m[1])
|
|
2433
|
+
: [];
|
|
2434
|
+
for (const field of required) {
|
|
2435
|
+
if (!emitted.has(field)) {
|
|
2436
|
+
this.diagnostics.error('analysis.channel-return-field', `Channel \`${ch.name}\` returns \`${field}\`, but the \`${ch.fromEvent}\` event carries no such field (it emits: ${[...emitted].join(', ') || '(none)'}).`, ch.span);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* `define pronouns` (ADR-242 D7): exactly the five case rows the
|
|
2443
|
+
* assembler's pronoun table keys — a missing or duplicate row is an
|
|
2444
|
+
* error (order is free; named rows, ruled Q-1). Forms pass through as
|
|
2445
|
+
* data; the language provider owns rendering them.
|
|
2446
|
+
*/
|
|
2447
|
+
buildPronounSet(decl) {
|
|
2448
|
+
const byCase = new Map();
|
|
2449
|
+
for (const row of decl.rows) {
|
|
2450
|
+
if (byCase.has(row.case)) {
|
|
2451
|
+
this.diagnostics.error('analysis.pronoun-set-duplicate-row', `\`define pronouns ${decl.name}\` already has a \`${row.case}\` row.`, row.span);
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
byCase.set(row.case, row.form);
|
|
2455
|
+
}
|
|
2456
|
+
for (const c of catalog_js_1.PRONOUN_CASES) {
|
|
2457
|
+
if (!byCase.has(c)) {
|
|
2458
|
+
this.diagnostics.error('analysis.pronoun-set-rows', `\`define pronouns ${decl.name}\` is missing its \`${c}\` row — all five case rows are required.`, decl.span);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
return {
|
|
2462
|
+
name: decl.name,
|
|
2463
|
+
forms: {
|
|
2464
|
+
subject: byCase.get('subject') ?? '',
|
|
2465
|
+
object: byCase.get('object') ?? '',
|
|
2466
|
+
possessive: byCase.get('possessive') ?? '',
|
|
2467
|
+
possessivePronoun: byCase.get('possessive-pronoun') ?? '',
|
|
2468
|
+
reflexive: byCase.get('reflexive') ?? '',
|
|
2469
|
+
},
|
|
2470
|
+
span: decl.span,
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
/** Resolve one emit-payload field (ADR-216) — key words join with a space, passed verbatim. */
|
|
2474
|
+
resolveEmitField(field, scope) {
|
|
2475
|
+
return { key: field.key.join(' '), value: this.resolveEmitValue(field.value, scope) };
|
|
2476
|
+
}
|
|
2477
|
+
/** Resolve one emit-payload value (ADR-216) — recursive over arrays/objects. */
|
|
2478
|
+
resolveEmitValue(value, scope) {
|
|
2479
|
+
switch (value.kind) {
|
|
2480
|
+
case 'literal':
|
|
2481
|
+
return { kind: 'literal', value: value.value, valueType: value.literalKind };
|
|
2482
|
+
case 'expr':
|
|
2483
|
+
return { kind: 'value', value: this.resolveValue(value.expr, scope) };
|
|
2484
|
+
case 'array':
|
|
2485
|
+
return { kind: 'array', items: value.items.map((i) => this.resolveEmitValue(i, scope)) };
|
|
2486
|
+
case 'object':
|
|
2487
|
+
return { kind: 'object', fields: value.fields.map((f) => this.resolveEmitField(f, scope)) };
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* `it` in a story-owned clause (ADR-236 D7): the story is the owner and
|
|
2492
|
+
* has no entity referent — the unbound-referent case no other clause
|
|
2493
|
+
* home can produce. A load error, never a silent undefined.
|
|
2494
|
+
*/
|
|
2495
|
+
reportStoryClauseIt(span) {
|
|
2496
|
+
this.diagnostics.error('analysis.story-clause-it', '`it` is not bound in a story-owned clause — the story has no entity referent. Name the entity, or use `the player`.', span);
|
|
2497
|
+
}
|
|
1207
2498
|
/**
|
|
1208
2499
|
* `the match` — the `each`-block binder (ratchet E3). Legal only inside
|
|
1209
2500
|
* an `each` body, at any nesting depth (the runtime binds innermost).
|
|
@@ -1240,8 +2531,11 @@ class Analyzer {
|
|
|
1240
2531
|
}
|
|
1241
2532
|
resolveRefValue(ref, scope) {
|
|
1242
2533
|
const words = ref.words.map((w) => w.toLowerCase());
|
|
1243
|
-
if (words.length === 1 && words[0] === 'it')
|
|
2534
|
+
if (words.length === 1 && words[0] === 'it') {
|
|
2535
|
+
if (scope.storyOwned)
|
|
2536
|
+
this.reportStoryClauseIt(ref.span);
|
|
1244
2537
|
return { kind: 'it' };
|
|
2538
|
+
}
|
|
1245
2539
|
// `the match` in NameRef positions (`change`/`move` targets, predicate
|
|
1246
2540
|
// things) resolves to the binder exactly as `it` does — before entity
|
|
1247
2541
|
// lookup; the name itself is reserved at declaration (E3/P3).
|
|
@@ -1255,6 +2549,8 @@ class Analyzer {
|
|
|
1255
2549
|
}
|
|
1256
2550
|
// `its <field>` in name position (`the actor has its food`).
|
|
1257
2551
|
if (words.length > 1 && words[0] === 'its') {
|
|
2552
|
+
if (scope.storyOwned)
|
|
2553
|
+
this.reportStoryClauseIt(ref.span);
|
|
1258
2554
|
return { kind: 'field', base: { kind: 'it' }, field: words.slice(1).join(' ') };
|
|
1259
2555
|
}
|
|
1260
2556
|
const scoped = this.resolveScopedWords(ref.words, scope);
|
|
@@ -1320,6 +2616,14 @@ class Analyzer {
|
|
|
1320
2616
|
return { kind: 'not', operand: this.resolveCondition(cond.operand, scope) };
|
|
1321
2617
|
case 'chance':
|
|
1322
2618
|
return { kind: 'chance', n: cond.n };
|
|
2619
|
+
case 'client-has': {
|
|
2620
|
+
// ADR-216: capability words are the closed platform flag set —
|
|
2621
|
+
// validated here, lowered to the camelCase platform key.
|
|
2622
|
+
if (!catalog_js_1.CLIENT_CAPABILITY_FLAGS.has(cond.capability)) {
|
|
2623
|
+
this.diagnostics.error('analysis.unknown-capability', `\`${cond.capability}\` is not a client capability flag${this.suggestText(cond.capability, [...catalog_js_1.CLIENT_CAPABILITY_FLAGS])}.`, cond.span);
|
|
2624
|
+
}
|
|
2625
|
+
return { kind: 'client-has', capability: (0, catalog_js_1.capabilityKeyOf)(cond.capability) };
|
|
2626
|
+
}
|
|
1323
2627
|
case 'condition-ref': {
|
|
1324
2628
|
if (this.conditionNames.has(cond.name)) {
|
|
1325
2629
|
// Never-guess gate (grammar log 2026-07-11): an OPEN condition is a
|
|
@@ -1462,15 +2766,15 @@ class Analyzer {
|
|
|
1462
2766
|
const validStates = subjectEntity?.states ?? (subject.kind === 'it' ? scope.ownStates ?? [] : []);
|
|
1463
2767
|
if (validStates.includes(word))
|
|
1464
2768
|
return { kind: 'symbol', name: word };
|
|
1465
|
-
if (
|
|
2769
|
+
if (catalog_js_1.TRAIT_ADJECTIVES.has(word))
|
|
1466
2770
|
return { kind: 'symbol', name: word };
|
|
1467
2771
|
// State adjectives (ratchet D1): read live from world trait state.
|
|
1468
|
-
if (
|
|
2772
|
+
if (catalog_js_1.STATE_ADJECTIVES.has(word))
|
|
1469
2773
|
return { kind: 'symbol', name: word };
|
|
1470
2774
|
const exactEntity = this.entities.find((e) => e.nameLower === word.toLowerCase() || e.aka.includes(word.toLowerCase()));
|
|
1471
2775
|
if (exactEntity)
|
|
1472
2776
|
return { kind: 'entity', id: exactEntity.id };
|
|
1473
|
-
const valid = [...validStates, ...
|
|
2777
|
+
const valid = [...validStates, ...catalog_js_1.TRAIT_ADJECTIVES, ...catalog_js_1.STATE_ADJECTIVES];
|
|
1474
2778
|
this.diagnostics.error('analysis.unknown-value', `\`${word}\` is not a state${subjectEntity ? ` of ${subjectEntity.nameLower}` : ''} or a known trait${this.suggestText(word, valid)}.`, span);
|
|
1475
2779
|
return { kind: 'symbol', name: word };
|
|
1476
2780
|
}
|
|
@@ -1495,6 +2799,15 @@ class Analyzer {
|
|
|
1495
2799
|
return;
|
|
1496
2800
|
if (owner && table?.has(`${owner.id}.${key}`))
|
|
1497
2801
|
return;
|
|
2802
|
+
// ADR-250 D4.6: book coverage counts as declaration. Covered only by
|
|
2803
|
+
// predicated books → warn once (off-book it renders nothing).
|
|
2804
|
+
if (this.bookKeys.has(key)) {
|
|
2805
|
+
if (!this.bookKeys.get(key) && !this.partialCoverageWarned.has(key)) {
|
|
2806
|
+
this.partialCoverageWarned.add(key);
|
|
2807
|
+
this.diagnostics.warning('analysis.phrasebook-partial-coverage', `\`${key}\` is only defined in conditional phrasebooks — when no book is active it renders nothing.`, span);
|
|
2808
|
+
}
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
1498
2811
|
const known = table ? [...table.keys()] : [];
|
|
1499
2812
|
this.diagnostics.error('analysis.missing-phrase', `Phrase \`${key}\` is not declared in ${DEFAULT_LOCALE}${this.suggestText(key, known)}.`, span);
|
|
1500
2813
|
}
|
|
@@ -1506,26 +2819,40 @@ class Analyzer {
|
|
|
1506
2819
|
checkMarkers() {
|
|
1507
2820
|
for (const [, table] of this.phrases) {
|
|
1508
2821
|
for (const [key, phrase] of table) {
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
2822
|
+
this.checkPhraseMarkers(key, phrase);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
// Phrasebook entries carry the same marker contract (ADR-250 D1 —
|
|
2826
|
+
// entries are ordinary phrase definitions).
|
|
2827
|
+
for (const book of this.phrasebookDecls) {
|
|
2828
|
+
if (!book.entries)
|
|
2829
|
+
continue;
|
|
2830
|
+
for (const [key, phrase] of Object.entries(book.entries)) {
|
|
2831
|
+
this.checkPhraseMarkers(`${book.name}.${key}`, phrase);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
checkPhraseMarkers(label, phrase) {
|
|
2836
|
+
for (const variant of phrase.variants) {
|
|
2837
|
+
// A variant carrying formatter-chain forms ({You}, {the item},
|
|
2838
|
+
// {verb:…}) is a TEMPLATE — its bare lowercase markers are chain
|
|
2839
|
+
// verbs ({open}), not producer references (Phase B: §3.2 trait
|
|
2840
|
+
// phrases). Full chain validation lands with the AC-9 contract.
|
|
2841
|
+
const isTemplate = variant.markers.some((m) => /[A-Z]/.test(m[0] ?? '') || m.includes(' ') || m.includes(':'));
|
|
2842
|
+
if (isTemplate)
|
|
2843
|
+
continue;
|
|
2844
|
+
for (const marker of variant.markers) {
|
|
2845
|
+
if (!/^[a-z][a-z0-9-]*$/.test(marker))
|
|
2846
|
+
continue;
|
|
2847
|
+
if (marker === 'br')
|
|
2848
|
+
continue; // built-in line break (grammar log 2026-07-10)
|
|
2849
|
+
if (this.hatchNames.has(marker))
|
|
2850
|
+
continue;
|
|
2851
|
+
if (this.phrases.get(DEFAULT_LOCALE)?.has(marker))
|
|
2852
|
+
continue;
|
|
2853
|
+
if (this.bookKeys.has(marker))
|
|
2854
|
+
continue; // book coverage counts (ADR-250 D4.6)
|
|
2855
|
+
this.diagnostics.error('analysis.unbound-marker', `\`{${marker}}\` in phrase \`${label}\` is not a declared text producer or phrase${this.suggestText(marker, [...this.hatchNames])}.`, phrase.span);
|
|
1529
2856
|
}
|
|
1530
2857
|
}
|
|
1531
2858
|
}
|