@sharpee/chord 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/analyzer.d.ts +11 -3
- package/analyzer.d.ts.map +1 -1
- package/analyzer.js +1202 -33
- package/analyzer.js.map +1 -1
- package/ast.d.ts +415 -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 +31 -13
- package/index.d.ts.map +1 -1
- package/index.js +110 -30
- package/index.js.map +1 -1
- package/ir.d.ts +290 -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/index.d.ts +21 -0
- package/manifests/index.d.ts.map +1 -0
- package/manifests/index.js +46 -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/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 +830 -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 +1507 -174
- 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/sharpee-chord-3.0.0.tgz +0 -0
package/analyzer.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
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 ir_js_1 = require("./ir.js");
|
|
6
9
|
/** Phase A stories register text in this locale (design.md §2.6). */
|
|
7
10
|
const DEFAULT_LOCALE = 'en-US';
|
|
11
|
+
/**
|
|
12
|
+
* Exit-direction opposites (parser DIRECTIONS vocabulary) — the same
|
|
13
|
+
* inference every plain exit line performs platform-side; here it backs
|
|
14
|
+
* the door mirror-line check (ADR-234 D2/D3, `checkDoors`).
|
|
15
|
+
*/
|
|
16
|
+
const OPPOSITE_DIRECTION = {
|
|
17
|
+
north: 'south', south: 'north', east: 'west', west: 'east',
|
|
18
|
+
northeast: 'southwest', southwest: 'northeast',
|
|
19
|
+
northwest: 'southeast', southeast: 'northwest',
|
|
20
|
+
up: 'down', down: 'up',
|
|
21
|
+
};
|
|
8
22
|
const PLAYER_WORDS = new Set(['player', 'you', 'yourself']);
|
|
9
23
|
/** Reserved-name gate text (David, 2026-07-12 — each package P3). */
|
|
10
24
|
const RESERVED_MATCH_MESSAGE = '`match` is reserved for the `each`-block binder `the match` (ratchet E3) — pick another name.';
|
|
@@ -15,8 +29,27 @@ const RESERVED_MATCH_MESSAGE = '`match` is reserved for the `each`-block binder
|
|
|
15
29
|
* block is the one authoring surface.
|
|
16
30
|
*/
|
|
17
31
|
const RESERVED_CHANNEL_KEYS = new Set(['present', 'entered', 'exited', 'disappeared', 'detail']);
|
|
32
|
+
/**
|
|
33
|
+
* Image layers the platform pre-registers (`image:background|main|
|
|
34
|
+
* overlay`, stdlib MEDIA_CHANNELS) — implied like the `main` ambient
|
|
35
|
+
* bed; `define layer` covers only layers beyond these (ADR-241 D3).
|
|
36
|
+
*/
|
|
37
|
+
const IMPLIED_IMAGE_LAYERS = new Set(['background', 'main', 'overlay']);
|
|
18
38
|
/** Ring 1 of the boolean-state gate (D9): literal booleans as state names. */
|
|
19
39
|
const BOOLEAN_STATE_WORDS = new Set(['true', 'false', 'yes', 'no']);
|
|
40
|
+
/**
|
|
41
|
+
* ADR-239 D4 topic normalization — ONE implementation for both halves of
|
|
42
|
+
* the lookup contract: the analyzer's overlap gates here, and the
|
|
43
|
+
* story-loader's runtime table lookup (which imports this). Rules:
|
|
44
|
+
* case-insensitive, leading article stripped, whitespace collapsed.
|
|
45
|
+
* Whole-topic equality only; never substring matching.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeTopic(text) {
|
|
48
|
+
const words = text.trim().toLowerCase().split(/\s+/);
|
|
49
|
+
if (words.length > 1 && (words[0] === 'the' || words[0] === 'a' || words[0] === 'an'))
|
|
50
|
+
words.shift();
|
|
51
|
+
return words.join(' ');
|
|
52
|
+
}
|
|
20
53
|
/**
|
|
21
54
|
* Negation prefixes/suffix for ring 3 of the boolean-state gate (D9):
|
|
22
55
|
* `not-`/`un-`/`non-` (hyphenated or fused), `no-` (hyphenated only — bare
|
|
@@ -61,6 +94,8 @@ function conditionFingerprint(cond) {
|
|
|
61
94
|
return `not(${conditionFingerprint(cond.operand)})`;
|
|
62
95
|
case 'chance':
|
|
63
96
|
return `chance:${cond.n}`;
|
|
97
|
+
case 'client-has':
|
|
98
|
+
return `client-has:${cond.capability}`;
|
|
64
99
|
case 'condition-ref':
|
|
65
100
|
return `cond:${cond.name}`;
|
|
66
101
|
case 'any-of':
|
|
@@ -97,6 +132,8 @@ const STANDARD_ACTION_ROLES = {
|
|
|
97
132
|
taking: ['taker', 'item'],
|
|
98
133
|
};
|
|
99
134
|
const TOP_SCOPE = { owner: null, fields: null, slots: null, ownStates: null, scoreOwner: null, inEach: false };
|
|
135
|
+
/** Scope of a story-owned clause (ADR-236 D7): no owner, `it` unbound. */
|
|
136
|
+
const STORY_SCOPE = { ...TOP_SCOPE, storyOwned: true };
|
|
100
137
|
function entityScope(owner) {
|
|
101
138
|
return { owner, fields: null, slots: null, ownStates: null, scoreOwner: owner?.id ?? null, inEach: false };
|
|
102
139
|
}
|
|
@@ -139,6 +176,7 @@ function conditionReferencesIt(cond) {
|
|
|
139
176
|
return conditionReferencesIt(cond.operand);
|
|
140
177
|
case 'chance':
|
|
141
178
|
case 'condition-ref':
|
|
179
|
+
case 'client-has':
|
|
142
180
|
return false;
|
|
143
181
|
case 'any-of':
|
|
144
182
|
case 'none-of':
|
|
@@ -187,10 +225,20 @@ class Analyzer {
|
|
|
187
225
|
hatchNames = new Set();
|
|
188
226
|
/** locale → key → IRPhrase */
|
|
189
227
|
phrases = new Map();
|
|
228
|
+
/** ADR-255: locale → override alias → IRPhrase (validated against MESSAGE_OVERRIDE_ALIASES). */
|
|
229
|
+
messageOverrides = new Map();
|
|
190
230
|
// Phase B namespaces:
|
|
191
231
|
traitNames = new Set();
|
|
192
232
|
/** action name → declared grammar-slot names. */
|
|
193
233
|
actionSlots = new Map();
|
|
234
|
+
/**
|
|
235
|
+
* emit event id (dotless) → the top-level payload field names it carries
|
|
236
|
+
* (ADR-253 D1). Collected across every `emit` during resolution so
|
|
237
|
+
* `checkChannelReturns` can flag a channel returning a field the event never
|
|
238
|
+
* emits. An event with no collected entry (a platform event, or one never
|
|
239
|
+
* emitted here) is left unchecked — the field set is unknown, not empty.
|
|
240
|
+
*/
|
|
241
|
+
emitFields = new Map();
|
|
194
242
|
/** Owner-qualified score id (`pygmy-goats.fed`, story-level bare) → worth. */
|
|
195
243
|
scoreNames = new Map();
|
|
196
244
|
/** Owner-qualified score declarations, for ir.scores emission. */
|
|
@@ -210,32 +258,91 @@ class Analyzer {
|
|
|
210
258
|
* feedable's `hungry` — resolution is across the composer's trait set).
|
|
211
259
|
*/
|
|
212
260
|
traitVisibleStates = new Map();
|
|
261
|
+
/**
|
|
262
|
+
* `define pronouns` sets by name (ADR-242 D7), collected in pass 1 so a
|
|
263
|
+
* `pronouns <word>` line resolves against sets declared later in the
|
|
264
|
+
* file. Span doubles as the pass-2 emission guard (family-channel
|
|
265
|
+
* precedent: an errored duplicate never emits a second entry).
|
|
266
|
+
*/
|
|
267
|
+
pronounSetDecls = new Map();
|
|
213
268
|
constructor(ast, diagnostics) {
|
|
214
269
|
this.ast = ast;
|
|
215
270
|
this.diagnostics = diagnostics;
|
|
216
271
|
}
|
|
272
|
+
/** Extensions admitted by validated `use` lines (ADR-215). */
|
|
273
|
+
usedExtensions = new Set();
|
|
274
|
+
/**
|
|
275
|
+
* Phrasebooks in arbitration order (ADR-250 D3): header `use phrasebook`
|
|
276
|
+
* lines first (file position), then body `define phrasebook` blocks in
|
|
277
|
+
* declaration order. Conditions resolve in run() (pass 2), after every
|
|
278
|
+
* entity symbol exists.
|
|
279
|
+
*/
|
|
280
|
+
phrasebookDecls = [];
|
|
281
|
+
/** Book name → declaring span (`analysis.duplicate-phrasebook` gate). */
|
|
282
|
+
phrasebookNames = new Map();
|
|
283
|
+
/**
|
|
284
|
+
* Story key → true when a predicate-less (default/always) book covers
|
|
285
|
+
* it. Book coverage counts as declaration for the missing-phrase gate
|
|
286
|
+
* (ADR-250 D4.6); predicated-only coverage earns the partial-coverage
|
|
287
|
+
* warning at first reference.
|
|
288
|
+
*/
|
|
289
|
+
bookKeys = new Map();
|
|
290
|
+
partialCoverageWarned = new Set();
|
|
217
291
|
run() {
|
|
218
292
|
this.collect();
|
|
293
|
+
// ADR-215: validate `use` lines against the manifest registry — an
|
|
294
|
+
// unknown name is a compile error (the loader's trusted registry check
|
|
295
|
+
// backstops rogue IR), a duplicate is one-`use`-per-extension.
|
|
296
|
+
for (const use of this.ast.header?.uses ?? []) {
|
|
297
|
+
const manifest = index_js_1.EXTENSION_MANIFESTS.get(use.name);
|
|
298
|
+
if (!manifest) {
|
|
299
|
+
const gated = [...index_js_1.EXTENSION_MANIFESTS.values()].filter((m) => !m.core).map((m) => m.name);
|
|
300
|
+
this.diagnostics.error('analysis.unknown-extension', `\`use ${use.name}\` names no trusted extension — known: ${gated.join(', ')}.`, use.span);
|
|
301
|
+
}
|
|
302
|
+
else if (manifest.core) {
|
|
303
|
+
// ADR-215 Q4: NPC vocabulary is CORE — always on, never `use`d.
|
|
304
|
+
this.diagnostics.error('analysis.extension-core', `\`${use.name}\` vocabulary is core — it is always available; remove the \`use\` line.`, use.span);
|
|
305
|
+
}
|
|
306
|
+
else if (this.usedExtensions.has(use.name)) {
|
|
307
|
+
this.diagnostics.error('analysis.duplicate-use', `\`use ${use.name}\` is already declared — one \`use\` per extension.`, use.span);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
this.usedExtensions.add(use.name);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
219
313
|
const ir = {
|
|
220
|
-
format:
|
|
314
|
+
format: ir_js_1.IR_FORMAT,
|
|
221
315
|
meta: {
|
|
222
316
|
title: this.ast.header?.title ?? '',
|
|
223
317
|
author: this.ast.header?.author ?? '',
|
|
224
318
|
fields: this.ast.header?.fields ?? {},
|
|
225
319
|
},
|
|
320
|
+
uses: [...this.usedExtensions],
|
|
226
321
|
story: {
|
|
227
322
|
states: this.storyStates,
|
|
228
323
|
reversible: this.ast.header?.statesReversible ?? false,
|
|
324
|
+
// Story-owned every-turn clauses (ADR-236 D7): built in STORY_SCOPE
|
|
325
|
+
// so `it` reports the unbound-referent gate; narration broadcasts
|
|
326
|
+
// (the story is everywhere — D11 satisfied trivially).
|
|
327
|
+
onClauses: (this.ast.header?.onClauses ?? []).map((c) => ({
|
|
328
|
+
...this.buildOnClause(c, STORY_SCOPE),
|
|
329
|
+
narration: 'broadcast',
|
|
330
|
+
})),
|
|
229
331
|
},
|
|
230
332
|
entities: [],
|
|
231
333
|
conditions: [],
|
|
232
334
|
phrases: { defaultLocale: DEFAULT_LOCALE, locales: {} },
|
|
335
|
+
messageOverrides: { defaultLocale: DEFAULT_LOCALE, locales: {} },
|
|
336
|
+
phrasebooks: [],
|
|
233
337
|
verbs: [],
|
|
234
338
|
hatches: [],
|
|
235
339
|
traits: [],
|
|
236
340
|
actions: [],
|
|
237
341
|
scores: this.scoreDecls,
|
|
238
342
|
sequences: [],
|
|
343
|
+
machines: [],
|
|
344
|
+
channels: [],
|
|
345
|
+
pronounSets: [],
|
|
239
346
|
hasHatches: false,
|
|
240
347
|
};
|
|
241
348
|
for (const decl of this.ast.declarations) {
|
|
@@ -262,6 +369,10 @@ class Analyzer {
|
|
|
262
369
|
ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: 'text', span: decl.span });
|
|
263
370
|
break;
|
|
264
371
|
case 'define-hatch':
|
|
372
|
+
// ADR-094 chain hatch: the name must be a replaceable stdlib chain.
|
|
373
|
+
if (decl.hatchKind === 'chain' && !catalog_js_1.STDLIB_CHAIN_NAMES.has(decl.name)) {
|
|
374
|
+
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);
|
|
375
|
+
}
|
|
265
376
|
ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: decl.hatchKind, span: decl.span });
|
|
266
377
|
break;
|
|
267
378
|
case 'define-trait':
|
|
@@ -270,6 +381,29 @@ class Analyzer {
|
|
|
270
381
|
case 'define-action':
|
|
271
382
|
ir.actions.push(this.buildAction(decl));
|
|
272
383
|
break;
|
|
384
|
+
case 'define-machine':
|
|
385
|
+
ir.machines.push(this.buildMachine(decl));
|
|
386
|
+
break;
|
|
387
|
+
case 'define-asset':
|
|
388
|
+
break; // collected in pass 1 — data references, nothing to emit
|
|
389
|
+
case 'define-family-channel':
|
|
390
|
+
// ADR-241 D2/D4: the declaration joins the channel manifest.
|
|
391
|
+
// The pass-1 span guard keeps an errored duplicate from
|
|
392
|
+
// producing a second entry.
|
|
393
|
+
if (this.familyChannels[decl.family].get(decl.name) === decl.span) {
|
|
394
|
+
ir.channels.push({ name: decl.name, family: decl.family, span: decl.span });
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
case 'define-channel':
|
|
398
|
+
ir.channels.push(this.buildChannel(decl));
|
|
399
|
+
break;
|
|
400
|
+
case 'define-pronouns':
|
|
401
|
+
// ADR-242 D7 — the pass-1 span guard keeps a shadowing/duplicate
|
|
402
|
+
// declaration from emitting (family-channel precedent).
|
|
403
|
+
if (this.pronounSetDecls.get(decl.name) === decl) {
|
|
404
|
+
ir.pronounSets.push(this.buildPronounSet(decl));
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
273
407
|
case 'define-sequence':
|
|
274
408
|
ir.sequences.push({
|
|
275
409
|
name: decl.name.join(' '),
|
|
@@ -295,18 +429,421 @@ class Analyzer {
|
|
|
295
429
|
entry.condition = this.resolveCondition(decl.condition, TOP_SCOPE);
|
|
296
430
|
}
|
|
297
431
|
break;
|
|
432
|
+
case 'override-message':
|
|
433
|
+
// ADR-255: same two-pass split as define-phrase — the optional
|
|
434
|
+
// `while <cond>` gate resolves here in pass 2. Only set when the
|
|
435
|
+
// alias survived pass-1 validation (an entry exists).
|
|
436
|
+
if (decl.condition) {
|
|
437
|
+
const entry = this.messageOverrides.get(DEFAULT_LOCALE)?.get(decl.alias);
|
|
438
|
+
if (entry)
|
|
439
|
+
entry.condition = this.resolveCondition(decl.condition, TOP_SCOPE);
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
case 'override-messages':
|
|
443
|
+
break; // flat entries collected in pass 1; no per-entry condition
|
|
298
444
|
case 'define-phrases':
|
|
299
445
|
break; // collected in pass 1
|
|
446
|
+
case 'define-phrasebook':
|
|
447
|
+
case 'import':
|
|
448
|
+
break; // collected/diagnosed in pass 1; conditions resolve below
|
|
449
|
+
case 'define-topics':
|
|
450
|
+
break; // applied onto owners after all entities are built (applyTopics)
|
|
300
451
|
}
|
|
301
452
|
}
|
|
453
|
+
// ADR-250 D3: books in arbitration order; predicates resolve here in
|
|
454
|
+
// pass 2 (an entity declared after the book may appear in its `while`).
|
|
455
|
+
ir.phrasebooks = this.phrasebookDecls.map((b) => ({
|
|
456
|
+
name: b.name,
|
|
457
|
+
source: b.source,
|
|
458
|
+
condition: b.condition ? this.resolveCondition(b.condition, TOP_SCOPE) : null,
|
|
459
|
+
...(b.entries ? { entries: b.entries } : {}),
|
|
460
|
+
span: b.span,
|
|
461
|
+
}));
|
|
462
|
+
// ADR-241 D3/D4: the implied `main` ambient bed — used by an ambient
|
|
463
|
+
// statement without a declaration — joins the channel manifest so the
|
|
464
|
+
// loader registers it (nothing platform-side pre-registers ambient).
|
|
465
|
+
if (this.impliedMainBedSpan && !this.familyChannels.ambient.has('main')) {
|
|
466
|
+
ir.channels.push({ name: 'main', family: 'ambient', span: this.impliedMainBedSpan });
|
|
467
|
+
}
|
|
302
468
|
for (const [locale, table] of this.phrases) {
|
|
303
469
|
ir.phrases.locales[locale] = Object.fromEntries(table);
|
|
304
470
|
}
|
|
471
|
+
for (const [locale, table] of this.messageOverrides) {
|
|
472
|
+
ir.messageOverrides.locales[locale] = Object.fromEntries(table);
|
|
473
|
+
}
|
|
305
474
|
ir.hasHatches = ir.hatches.length > 0;
|
|
475
|
+
this.applyTopics(ir.entities);
|
|
476
|
+
this.checkRegions(ir.entities);
|
|
477
|
+
this.checkDoors(ir.entities);
|
|
306
478
|
this.checkMarkers();
|
|
307
479
|
this.checkDescriptionMarkers();
|
|
480
|
+
this.checkChannelReturns(ir.channels); // ADR-253 D1: return-field cross-check (all emits collected)
|
|
308
481
|
return ir;
|
|
309
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* ADR-236 D2/D3 never-guess gates over the whole region graph: member
|
|
485
|
+
* kinds (rooms or regions only), single direct membership (RoomTrait's
|
|
486
|
+
* `regionId` is single-valued — an ancestor+descendant listing is the
|
|
487
|
+
* same error), single parent per region, no memberless regions, no
|
|
488
|
+
* containment cycles. Runs after every entity is built so cross-entity
|
|
489
|
+
* lookups and spans are all available.
|
|
490
|
+
*/
|
|
491
|
+
checkRegions(entities) {
|
|
492
|
+
const byId = new Map(entities.map((e) => [e.id, e]));
|
|
493
|
+
const isRegionEntity = (e) => e.kinds.some((k) => k.name === 'region');
|
|
494
|
+
const regions = entities.filter(isRegionEntity);
|
|
495
|
+
// Memberless region: declared-but-unanswerable, uniformly hard (D2 —
|
|
496
|
+
// ruled no warning tier; its daemon could otherwise silently never fire).
|
|
497
|
+
for (const region of regions) {
|
|
498
|
+
if (region.containing.length === 0) {
|
|
499
|
+
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);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Direct membership is stated exactly once, graph-wide.
|
|
503
|
+
const roomMemberOf = new Map();
|
|
504
|
+
const parentOf = new Map();
|
|
505
|
+
for (const region of regions) {
|
|
506
|
+
for (const member of region.containing) {
|
|
507
|
+
const target = byId.get(member.id);
|
|
508
|
+
if (!target)
|
|
509
|
+
continue; // unresolved — already reported by resolveEntityId
|
|
510
|
+
if (isRegionEntity(target)) {
|
|
511
|
+
const prior = parentOf.get(member.id);
|
|
512
|
+
if (prior) {
|
|
513
|
+
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);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
parentOf.set(member.id, { parent: region, span: member.span });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else if (target.kinds.some((k) => k.name === 'room')) {
|
|
520
|
+
const prior = roomMemberOf.get(member.id);
|
|
521
|
+
if (prior) {
|
|
522
|
+
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);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
roomMemberOf.set(member.id, { region, span: member.span });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
const kind = target.kinds[0]?.name ?? 'plain thing';
|
|
530
|
+
this.diagnostics.error('analysis.region-member-kind', `\`${target.name}\` is a ${kind} — \`containing\` members must be rooms or regions.`, member.span);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Containment cycles (walking child → parent; two-parents kept the
|
|
535
|
+
// first edge, so the graph is functional and one walk per region ends).
|
|
536
|
+
const walked = new Map();
|
|
537
|
+
for (const region of regions) {
|
|
538
|
+
if (walked.has(region.id))
|
|
539
|
+
continue;
|
|
540
|
+
const path = [];
|
|
541
|
+
let cur = region.id;
|
|
542
|
+
while (cur !== undefined && !walked.has(cur)) {
|
|
543
|
+
walked.set(cur, 'visiting');
|
|
544
|
+
path.push(cur);
|
|
545
|
+
const edge = parentOf.get(cur);
|
|
546
|
+
const next = edge?.parent.id;
|
|
547
|
+
if (next !== undefined && walked.get(next) === 'visiting') {
|
|
548
|
+
const names = [...path.slice(path.indexOf(next)), next].map((id) => byId.get(id)?.name ?? id);
|
|
549
|
+
this.diagnostics.error('analysis.region-cycle', `Region containment cycle: ${names.map((n) => `\`${n}\``).join(' → ')}.`, edge.span);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
cur = next;
|
|
553
|
+
}
|
|
554
|
+
for (const id of path)
|
|
555
|
+
walked.set(id, 'done');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* ADR-234 D3 never-guess gates over the whole door graph: every
|
|
560
|
+
* `through` target is a door, a door connects exactly one room pair
|
|
561
|
+
* (the only legal second reference is the exact mirror — other side,
|
|
562
|
+
* opposite direction), and every declared door is referenced somewhere
|
|
563
|
+
* (an unconnected door is unanswerable, same hard class as a
|
|
564
|
+
* memberless region). Runs after every entity is built so cross-entity
|
|
565
|
+
* lookups and spans are all available.
|
|
566
|
+
*/
|
|
567
|
+
checkDoors(entities) {
|
|
568
|
+
const byId = new Map(entities.map((e) => [e.id, e]));
|
|
569
|
+
const isDoorEntity = (e) => e.kinds.some((k) => k.name === 'door');
|
|
570
|
+
// First `through` reference per door — the canonical pair.
|
|
571
|
+
const firstRef = new Map();
|
|
572
|
+
// Doors whose mirror side has already been stated.
|
|
573
|
+
const mirrored = new Set();
|
|
574
|
+
for (const owner of entities) {
|
|
575
|
+
for (const exit of owner.exits) {
|
|
576
|
+
if (exit.via === null || exit.via === '')
|
|
577
|
+
continue; // plain, or unresolved (already reported)
|
|
578
|
+
const door = byId.get(exit.via);
|
|
579
|
+
if (!door)
|
|
580
|
+
continue;
|
|
581
|
+
if (!isDoorEntity(door)) {
|
|
582
|
+
const kind = door.kinds[0]?.name ?? 'plain thing';
|
|
583
|
+
this.diagnostics.error('analysis.door-through-kind', `\`${door.name}\` is a ${kind} — \`through\` names a door (\`create the ${door.name} / a door\`).`, exit.span);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const first = firstRef.get(door.id);
|
|
587
|
+
if (!first) {
|
|
588
|
+
firstRef.set(door.id, { owner, exit });
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const samePair = (owner.id === first.owner.id && exit.to === first.exit.to)
|
|
592
|
+
|| (owner.id === first.exit.to && exit.to === first.owner.id);
|
|
593
|
+
if (!samePair) {
|
|
594
|
+
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);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const isMirror = owner.id === first.exit.to
|
|
598
|
+
&& exit.to === first.owner.id
|
|
599
|
+
&& exit.direction === OPPOSITE_DIRECTION[first.exit.direction]
|
|
600
|
+
&& !mirrored.has(door.id);
|
|
601
|
+
if (isMirror) {
|
|
602
|
+
mirrored.add(door.id);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
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);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Plain mirror of a door exit (platform-issue-sweep Phase 8 #6): a
|
|
610
|
+
// plain `<dir> to <room>` line whose reverse side is door-wired would
|
|
611
|
+
// re-stamp BOTH directions without the door at load, silently unwiring
|
|
612
|
+
// it (the loader's connectRooms stamps the reverse too). The author
|
|
613
|
+
// must name the door — or drop the line entirely, since one door-wired
|
|
614
|
+
// side already connects both rooms.
|
|
615
|
+
for (const owner of entities) {
|
|
616
|
+
for (const exit of owner.exits) {
|
|
617
|
+
if (exit.via !== null)
|
|
618
|
+
continue;
|
|
619
|
+
const target = byId.get(exit.to);
|
|
620
|
+
if (!target)
|
|
621
|
+
continue;
|
|
622
|
+
const reverse = target.exits.find((e) => e.via !== null && e.via !== '' && e.to === owner.id
|
|
623
|
+
&& e.direction === OPPOSITE_DIRECTION[exit.direction]);
|
|
624
|
+
if (reverse) {
|
|
625
|
+
const doorName = byId.get(reverse.via)?.name ?? reverse.via;
|
|
626
|
+
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);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// Unconnected door: declared-but-unanswerable, uniformly hard (D3 —
|
|
631
|
+
// same class as region-memberless; its room pair could never resolve).
|
|
632
|
+
for (const door of entities.filter(isDoorEntity)) {
|
|
633
|
+
if (!firstRef.has(door.id)) {
|
|
634
|
+
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);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* ADR-239 D4's never-guess table gates + lowering: resolve each
|
|
640
|
+
* `define topics` block onto its owner entity's `topics` rows. Runs
|
|
641
|
+
* after every entity is built so owner kinds and cross-tier name
|
|
642
|
+
* lookups are all available. Gates (each its own diagnostic): a second
|
|
643
|
+
* block for the same owner, a non-person host, a duplicate topic
|
|
644
|
+
* (entity or normalized quoted text, aliases included), and a quoted
|
|
645
|
+
* entry colliding with the name/aka of an entity used in an
|
|
646
|
+
* entity-tier row of the same table.
|
|
647
|
+
*/
|
|
648
|
+
applyTopics(entities) {
|
|
649
|
+
const byId = new Map(entities.map((e) => [e.id, e]));
|
|
650
|
+
/** owner id → first block's span (duplicate-block gate). */
|
|
651
|
+
const blockOwners = new Map();
|
|
652
|
+
for (const decl of this.ast.declarations) {
|
|
653
|
+
if (decl.kind !== 'define-topics')
|
|
654
|
+
continue;
|
|
655
|
+
if (decl.owner.words.length === 0)
|
|
656
|
+
continue; // header parse error already reported
|
|
657
|
+
const ownerId = this.resolveEntityId(decl.owner);
|
|
658
|
+
if (!ownerId)
|
|
659
|
+
continue; // unknown/ambiguous — standard errors already reported
|
|
660
|
+
const owner = byId.get(ownerId);
|
|
661
|
+
const sym = this.byId.get(ownerId) ?? null;
|
|
662
|
+
if (!owner)
|
|
663
|
+
continue;
|
|
664
|
+
const first = blockOwners.get(ownerId);
|
|
665
|
+
if (first) {
|
|
666
|
+
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);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
blockOwners.set(ownerId, decl.span);
|
|
670
|
+
if (!owner.kinds.some((k) => k.name === 'person')) {
|
|
671
|
+
const kind = owner.kinds[0] ? `a ${owner.kinds[0].name}` : 'a plain thing';
|
|
672
|
+
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);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
// Entity-tier refs resolve once, up front: the cross-tier collision
|
|
676
|
+
// gate must see every entity-tier row's names, even those declared
|
|
677
|
+
// AFTER a colliding quoted row.
|
|
678
|
+
const resolvedRefs = decl.rows.map((row) => row.filter.kind === 'entity' ? this.resolveEntityId(row.filter.ref) : null);
|
|
679
|
+
/** normalized name/aka of entity-tier row entities → display name. */
|
|
680
|
+
const entityTierNames = new Map();
|
|
681
|
+
for (const id of resolvedRefs) {
|
|
682
|
+
if (!id)
|
|
683
|
+
continue;
|
|
684
|
+
const rowSym = this.byId.get(id);
|
|
685
|
+
if (!rowSym)
|
|
686
|
+
continue;
|
|
687
|
+
entityTierNames.set(normalizeTopic(rowSym.nameLower), rowSym.nameLower);
|
|
688
|
+
for (const alias of rowSym.aka)
|
|
689
|
+
entityTierNames.set(normalizeTopic(alias), rowSym.nameLower);
|
|
690
|
+
}
|
|
691
|
+
const scope = entityScope(sym);
|
|
692
|
+
const rows = [];
|
|
693
|
+
const seenEntities = new Map();
|
|
694
|
+
const seenTexts = new Map();
|
|
695
|
+
for (let i = 0; i < decl.rows.length; i++) {
|
|
696
|
+
const row = decl.rows[i];
|
|
697
|
+
if (row.filter.kind === 'entity') {
|
|
698
|
+
const id = resolvedRefs[i];
|
|
699
|
+
if (!id)
|
|
700
|
+
continue; // unresolved — already reported
|
|
701
|
+
const dup = seenEntities.get(id);
|
|
702
|
+
if (dup) {
|
|
703
|
+
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);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
seenEntities.set(id, row.span);
|
|
707
|
+
rows.push({
|
|
708
|
+
filter: { kind: 'entity', id },
|
|
709
|
+
body: row.body.map((s) => this.resolveStatement(s, scope)),
|
|
710
|
+
span: row.span,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
const texts = [row.filter.primary, ...row.filter.aliases];
|
|
715
|
+
let rejected = false;
|
|
716
|
+
for (const text of texts) {
|
|
717
|
+
const norm = normalizeTopic(text);
|
|
718
|
+
const dup = seenTexts.get(norm);
|
|
719
|
+
if (dup) {
|
|
720
|
+
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);
|
|
721
|
+
rejected = true;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
seenTexts.set(norm, row.span);
|
|
725
|
+
const collidesWith = entityTierNames.get(norm);
|
|
726
|
+
if (collidesWith) {
|
|
727
|
+
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);
|
|
728
|
+
rejected = true;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (rejected)
|
|
732
|
+
continue;
|
|
733
|
+
rows.push({
|
|
734
|
+
filter: { kind: 'text', primary: row.filter.primary, aliases: row.filter.aliases },
|
|
735
|
+
body: row.body.map((s) => this.resolveStatement(s, scope)),
|
|
736
|
+
span: row.span,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
this.checkPhaseOrder(row.body, { mutated: false });
|
|
740
|
+
}
|
|
741
|
+
owner.topics = rows;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/** Machine names seen (duplicate gate). */
|
|
745
|
+
machineNames = new Set();
|
|
746
|
+
/** Declared media assets (ADR-216): name → kind + path. */
|
|
747
|
+
assets = new Map();
|
|
748
|
+
/**
|
|
749
|
+
* Build one `define machine` (ADR-215 `use state-machines` depth):
|
|
750
|
+
* gated on the `use`, states/targets/roles validated, single-word
|
|
751
|
+
* triggers resolved (declared condition or story state wins, else an
|
|
752
|
+
* action gerund), bodies in STORY_SCOPE (`it` unbound — the machine is
|
|
753
|
+
* story-owned).
|
|
754
|
+
*/
|
|
755
|
+
buildMachine(decl) {
|
|
756
|
+
if (!this.usedExtensions.has('state-machines')) {
|
|
757
|
+
this.diagnostics.error('analysis.extension-not-used', '`define machine` is `state-machines` extension vocabulary — add `use state-machines` to the story header.', decl.span);
|
|
758
|
+
}
|
|
759
|
+
const name = decl.name.join(' ');
|
|
760
|
+
if (this.machineNames.has(name)) {
|
|
761
|
+
this.diagnostics.error('analysis.duplicate-machine', `A machine named \`${name}\` already exists.`, decl.span);
|
|
762
|
+
}
|
|
763
|
+
this.machineNames.add(name);
|
|
764
|
+
const stateNames = new Set(decl.states.map((s) => s.name));
|
|
765
|
+
if (decl.states.length === 0) {
|
|
766
|
+
this.diagnostics.error('analysis.machine-states', `Machine \`${name}\` declares no states.`, decl.span);
|
|
767
|
+
}
|
|
768
|
+
if (decl.initialState === null) {
|
|
769
|
+
this.diagnostics.error('analysis.machine-starts', `Machine \`${name}\` needs a \`starts <state>\` line.`, decl.span);
|
|
770
|
+
}
|
|
771
|
+
else if (!stateNames.has(decl.initialState)) {
|
|
772
|
+
this.diagnostics.error('analysis.machine-starts', `\`starts ${decl.initialState}\` names no declared state of \`${name}\`${this.suggestText(decl.initialState, [...stateNames])}.`, decl.span);
|
|
773
|
+
}
|
|
774
|
+
const roles = new Map();
|
|
775
|
+
for (const role of decl.roles) {
|
|
776
|
+
if (roles.has(role.name)) {
|
|
777
|
+
this.diagnostics.error('analysis.machine-role', `Role \`${role.name}\` is declared twice on \`${name}\`.`, role.span);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const entity = this.resolveEntityId(role.entity);
|
|
781
|
+
if (entity !== null)
|
|
782
|
+
roles.set(role.name, entity);
|
|
783
|
+
}
|
|
784
|
+
const buildTransition = (t) => {
|
|
785
|
+
if (!stateNames.has(t.target)) {
|
|
786
|
+
this.diagnostics.error('analysis.machine-target', `\`${t.target}\` names no declared state of \`${name}\`${this.suggestText(t.target, [...stateNames])}.`, t.span);
|
|
787
|
+
}
|
|
788
|
+
let trigger;
|
|
789
|
+
switch (t.trigger.kind) {
|
|
790
|
+
case 'event':
|
|
791
|
+
trigger = { kind: 'event', event: t.trigger.event };
|
|
792
|
+
break;
|
|
793
|
+
case 'condition':
|
|
794
|
+
trigger = { kind: 'condition', condition: this.resolveCondition(t.trigger.condition, STORY_SCOPE) };
|
|
795
|
+
break;
|
|
796
|
+
case 'word': {
|
|
797
|
+
// Vocabulary-free parse: a declared condition or story state is a
|
|
798
|
+
// condition trigger; anything else is an action gerund.
|
|
799
|
+
if (this.conditionNames.has(t.trigger.word) || this.storyStates.includes(t.trigger.word)) {
|
|
800
|
+
trigger = {
|
|
801
|
+
kind: 'condition',
|
|
802
|
+
condition: this.resolveCondition({ kind: 'condition-ref', name: t.trigger.word, span: t.trigger.span }, STORY_SCOPE),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
trigger = { kind: 'action', action: t.trigger.word, target: null };
|
|
807
|
+
}
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
case 'action': {
|
|
811
|
+
let target = null;
|
|
812
|
+
if (t.trigger.target) {
|
|
813
|
+
const words = t.trigger.target.words.map((w) => w.toLowerCase());
|
|
814
|
+
if (words.length === 1 && roles.has(words[0])) {
|
|
815
|
+
target = `$${words[0]}`; // the platform binding convention
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
target = this.resolveEntityId(t.trigger.target);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
trigger = { kind: 'action', action: t.trigger.action, target };
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
trigger,
|
|
827
|
+
condition: t.condition ? this.resolveCondition(t.condition, STORY_SCOPE) : null,
|
|
828
|
+
target: t.target,
|
|
829
|
+
span: t.span,
|
|
830
|
+
};
|
|
831
|
+
};
|
|
832
|
+
return {
|
|
833
|
+
name,
|
|
834
|
+
roles: [...roles].map(([roleName, entity]) => ({ name: roleName, entity })),
|
|
835
|
+
initialState: decl.initialState ?? decl.states[0]?.name ?? '',
|
|
836
|
+
states: decl.states.map((s) => ({
|
|
837
|
+
name: s.name,
|
|
838
|
+
terminal: s.terminal,
|
|
839
|
+
transitions: s.transitions.map(buildTransition),
|
|
840
|
+
onEnter: s.onEnter.map((stmt) => this.resolveStatement(stmt, STORY_SCOPE)),
|
|
841
|
+
onExit: s.onExit.map((stmt) => this.resolveStatement(stmt, STORY_SCOPE)),
|
|
842
|
+
span: s.span,
|
|
843
|
+
})),
|
|
844
|
+
span: decl.span,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
310
847
|
/** Resolve a `when <owner> becomes <state>` step anchor (ratchet D10). */
|
|
311
848
|
resolveStepAnchor(step) {
|
|
312
849
|
if (step.timing !== 'becomes' || !step.owner || !step.state)
|
|
@@ -377,7 +914,7 @@ class Analyzer {
|
|
|
377
914
|
if (clause.binding === 'every-turn')
|
|
378
915
|
continue;
|
|
379
916
|
let key = `${clause.clauseKind}|${clause.action}|${clause.binding}|${clause.role ?? ''}`;
|
|
380
|
-
if (
|
|
917
|
+
if (catalog_js_1.EVENT_VERBS.has(clause.action)) {
|
|
381
918
|
key += `|${clause.condition ? conditionFingerprint(clause.condition) : ''}`;
|
|
382
919
|
}
|
|
383
920
|
const first = seen.get(key);
|
|
@@ -446,6 +983,16 @@ class Analyzer {
|
|
|
446
983
|
this.storyStates = this.ast.header.states.map((s) => s.name);
|
|
447
984
|
for (const s of this.ast.header.scores)
|
|
448
985
|
this.collectScore(s.name, s.worth, s.span, null);
|
|
986
|
+
// Header `on every turn` clause bodies host inline phrase prose like
|
|
987
|
+
// every other body context (platform-issue-sweep Phase 8 #14): story-
|
|
988
|
+
// owned, so ownerless/bare-key scope — the same registration the five
|
|
989
|
+
// pre-existing contexts use.
|
|
990
|
+
for (const clause of this.ast.header.onClauses)
|
|
991
|
+
this.collectInlineTexts(clause.body);
|
|
992
|
+
// `use phrasebook` lines (ADR-250 D2/D3) — header position puts every
|
|
993
|
+
// used book ahead of body-declared books in the arbitration order.
|
|
994
|
+
for (const use of this.ast.header.usePhrasebooks)
|
|
995
|
+
this.collectUsePhrasebook(use);
|
|
449
996
|
}
|
|
450
997
|
for (const decl of this.ast.declarations) {
|
|
451
998
|
if (decl.kind === 'create')
|
|
@@ -466,6 +1013,50 @@ class Analyzer {
|
|
|
466
1013
|
this.collectPhrasesBlock(decl);
|
|
467
1014
|
else if (decl.kind === 'define-phrase')
|
|
468
1015
|
this.collectPhraseDecl(decl);
|
|
1016
|
+
else if (decl.kind === 'define-phrasebook')
|
|
1017
|
+
this.collectPhrasebook(decl);
|
|
1018
|
+
else if (decl.kind === 'override-message')
|
|
1019
|
+
this.collectOverrideMessage(decl);
|
|
1020
|
+
else if (decl.kind === 'override-messages')
|
|
1021
|
+
this.collectOverrideMessages(decl);
|
|
1022
|
+
else if (decl.kind === 'import') {
|
|
1023
|
+
// The compile host resolves imports before analysis (splicing the
|
|
1024
|
+
// fragment's declarations at this position); one surviving here means
|
|
1025
|
+
// no resolver ran — direct parse/analyze callers included.
|
|
1026
|
+
this.diagnostics.error('analysis.import-unresolved', `\`import "${decl.path}"\` was not resolved — compile with an \`importResolver\` host hook.`, decl.span);
|
|
1027
|
+
}
|
|
1028
|
+
else if (decl.kind === 'define-asset') {
|
|
1029
|
+
// ADR-216: declared media assets — DATA references, one namespace.
|
|
1030
|
+
if (this.assets.has(decl.name)) {
|
|
1031
|
+
this.diagnostics.error('analysis.duplicate-asset', `An asset named \`${decl.name}\` already exists.`, decl.span);
|
|
1032
|
+
}
|
|
1033
|
+
else {
|
|
1034
|
+
this.assets.set(decl.name, { kind: decl.assetKind, path: decl.path, span: decl.span });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
else if (decl.kind === 'define-family-channel') {
|
|
1038
|
+
// ADR-241 D2: named family channels, per-family namespace.
|
|
1039
|
+
const family = this.familyChannels[decl.family];
|
|
1040
|
+
if (family.has(decl.name)) {
|
|
1041
|
+
this.diagnostics.error('analysis.duplicate-channel', `An ${decl.family === 'ambient' ? 'ambient bed' : 'image layer'} named \`${decl.name}\` is already declared.`, decl.span);
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
family.set(decl.name, decl.span);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
else if (decl.kind === 'define-pronouns') {
|
|
1048
|
+
// ADR-242 D7: one namespace beside the standard four — shadowing a
|
|
1049
|
+
// standard word and redefining a set are each errors, never a merge.
|
|
1050
|
+
if (catalog_js_1.PRONOUN_WORDS.has(decl.name)) {
|
|
1051
|
+
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);
|
|
1052
|
+
}
|
|
1053
|
+
else if (this.pronounSetDecls.has(decl.name)) {
|
|
1054
|
+
this.diagnostics.error('analysis.duplicate-pronoun-set', `A pronoun set named \`${decl.name}\` is already defined.`, decl.span);
|
|
1055
|
+
}
|
|
1056
|
+
else {
|
|
1057
|
+
this.pronounSetDecls.set(decl.name, decl);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
469
1060
|
else if (decl.kind === 'define-trait') {
|
|
470
1061
|
this.traitNames.add(decl.name);
|
|
471
1062
|
for (const field of decl.data) {
|
|
@@ -512,6 +1103,15 @@ class Analyzer {
|
|
|
512
1103
|
for (const step of decl.steps)
|
|
513
1104
|
this.collectInlineTexts(step.body);
|
|
514
1105
|
}
|
|
1106
|
+
else if (decl.kind === 'define-machine') {
|
|
1107
|
+
// Machine state bodies (`on enter` / `on exit`) host inline phrase
|
|
1108
|
+
// prose like every other body context (platform-issue-sweep Phase 8
|
|
1109
|
+
// #14) — ownerless/bare-key scope, matching the header clauses.
|
|
1110
|
+
for (const state of decl.states) {
|
|
1111
|
+
this.collectInlineTexts(state.onEnter);
|
|
1112
|
+
this.collectInlineTexts(state.onExit);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
515
1115
|
}
|
|
516
1116
|
// Trait-declared states reach every composer (ratchet D8): merge each
|
|
517
1117
|
// trait's state set into the composing entity's, in composition order.
|
|
@@ -613,6 +1213,37 @@ class Analyzer {
|
|
|
613
1213
|
for (const clause of e.decl.onClauses)
|
|
614
1214
|
this.collectInlineTexts(clause.body, e.id);
|
|
615
1215
|
}
|
|
1216
|
+
// Topic-table row bodies are entity-owned (ADR-239): their inline
|
|
1217
|
+
// phrases register owner-scoped like clause bodies. Owner resolution
|
|
1218
|
+
// here is silent — an unresolved owner is pass 2's error (applyTopics).
|
|
1219
|
+
for (const decl of this.ast.declarations) {
|
|
1220
|
+
if (decl.kind !== 'define-topics')
|
|
1221
|
+
continue;
|
|
1222
|
+
const owner = this.findEntitySilent(decl.owner);
|
|
1223
|
+
if (!owner)
|
|
1224
|
+
continue;
|
|
1225
|
+
for (const row of decl.rows)
|
|
1226
|
+
this.collectInlineTexts(row.body, owner.id);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Resolve a name to an entity symbol with resolveEntityId's exact
|
|
1231
|
+
* precedence (exact name → alias → unique in-order subset) but NO
|
|
1232
|
+
* diagnostics — for pass-1 uses where pass 2 will report the miss.
|
|
1233
|
+
*/
|
|
1234
|
+
findEntitySilent(ref) {
|
|
1235
|
+
const lower = ref.words.join(' ').toLowerCase();
|
|
1236
|
+
const exact = this.entities.filter((e) => e.nameLower === lower);
|
|
1237
|
+
if (exact.length === 1)
|
|
1238
|
+
return exact[0];
|
|
1239
|
+
const byAlias = this.entities.filter((e) => e.aka.includes(lower));
|
|
1240
|
+
if (byAlias.length === 1)
|
|
1241
|
+
return byAlias[0];
|
|
1242
|
+
const refWords = ref.words.map((w) => w.toLowerCase());
|
|
1243
|
+
const subset = this.entities.filter((e) => isInOrderSubset(refWords, e.nameWords));
|
|
1244
|
+
if (subset.length === 1)
|
|
1245
|
+
return subset[0];
|
|
1246
|
+
return null;
|
|
616
1247
|
}
|
|
617
1248
|
/**
|
|
618
1249
|
* Declare-and-emit sugar (§2.6): a `phrase <key>` statement carrying an
|
|
@@ -667,7 +1298,7 @@ class Analyzer {
|
|
|
667
1298
|
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
1299
|
}
|
|
669
1300
|
}
|
|
670
|
-
for (const { pair, trait } of
|
|
1301
|
+
for (const { pair, trait } of catalog_js_1.PLATFORM_STATE_PAIRS) {
|
|
671
1302
|
if (names.includes(pair[0]) && names.includes(pair[1])) {
|
|
672
1303
|
const at = states.find((s) => s.name === pair[1]) ?? states[0];
|
|
673
1304
|
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);
|
|
@@ -779,9 +1410,111 @@ class Analyzer {
|
|
|
779
1410
|
span: decl.span,
|
|
780
1411
|
});
|
|
781
1412
|
}
|
|
1413
|
+
/** ADR-255: `override message <alias>` — full phrase body under an ACL alias. */
|
|
1414
|
+
collectOverrideMessage(decl) {
|
|
1415
|
+
this.registerMessageOverride(DEFAULT_LOCALE, decl.alias, decl.span, {
|
|
1416
|
+
strategy: decl.strategy ?? null,
|
|
1417
|
+
...(decl.verbatim ? { verbatim: true } : {}),
|
|
1418
|
+
variants: decl.variants.map((v) => this.variantOf(v)),
|
|
1419
|
+
span: decl.span,
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
/** ADR-255: `override messages <locale>` — flat `alias: text` entries. */
|
|
1423
|
+
collectOverrideMessages(decl) {
|
|
1424
|
+
for (const entry of decl.entries) {
|
|
1425
|
+
this.registerMessageOverride(decl.locale, entry.key, entry.span, {
|
|
1426
|
+
strategy: null,
|
|
1427
|
+
variants: [this.variantOf(entry.value)],
|
|
1428
|
+
span: entry.span,
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* ADR-255 D4: register a message override, gating the alias against the
|
|
1434
|
+
* ACL catalog (`analysis.unknown-message-alias`, mirroring `unknown-channel`)
|
|
1435
|
+
* and rejecting a duplicate. The alias — never a dotted platform id — is
|
|
1436
|
+
* resolved to `if.action.*` loader-side (Interface Contract 3).
|
|
1437
|
+
*/
|
|
1438
|
+
registerMessageOverride(locale, alias, span, phrase) {
|
|
1439
|
+
if (!alias)
|
|
1440
|
+
return; // header parse error already reported
|
|
1441
|
+
if (!catalog_js_1.MESSAGE_OVERRIDE_ALIASES.has(alias)) {
|
|
1442
|
+
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);
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
let table = this.messageOverrides.get(locale);
|
|
1446
|
+
if (!table) {
|
|
1447
|
+
table = new Map();
|
|
1448
|
+
this.messageOverrides.set(locale, table);
|
|
1449
|
+
}
|
|
1450
|
+
if (table.has(alias)) {
|
|
1451
|
+
this.diagnostics.error('analysis.duplicate-message-override', `Message override \`${alias}\` is declared twice in ${locale}.`, span);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
table.set(alias, phrase);
|
|
1455
|
+
}
|
|
782
1456
|
variantOf(value) {
|
|
783
1457
|
return { text: value.text, markers: value.markers.map((m) => m.content) };
|
|
784
1458
|
}
|
|
1459
|
+
/** Duplicate-name gate shared by `define phrasebook` and `use phrasebook`. */
|
|
1460
|
+
registerPhrasebookName(name, span) {
|
|
1461
|
+
const first = this.phrasebookNames.get(name);
|
|
1462
|
+
if (first) {
|
|
1463
|
+
this.diagnostics.error('analysis.duplicate-phrasebook', `A phrasebook named \`${name}\` is already declared at line ${first.line}.`, span);
|
|
1464
|
+
return false;
|
|
1465
|
+
}
|
|
1466
|
+
this.phrasebookNames.set(name, span);
|
|
1467
|
+
return true;
|
|
1468
|
+
}
|
|
1469
|
+
/** Book coverage bookkeeping: key → covered by a default (always) book? */
|
|
1470
|
+
recordBookKey(key, isAlways) {
|
|
1471
|
+
this.bookKeys.set(key, (this.bookKeys.get(key) ?? false) || isAlways);
|
|
1472
|
+
}
|
|
1473
|
+
/** `use phrasebook <name> [while <cond>]` (ADR-250 D2/D3) — pass 1. */
|
|
1474
|
+
collectUsePhrasebook(use) {
|
|
1475
|
+
const manifest = phrasebooks_js_1.PHRASEBOOK_REGISTRY.get(use.name);
|
|
1476
|
+
if (!manifest) {
|
|
1477
|
+
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);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if (!this.registerPhrasebookName(use.name, use.span))
|
|
1481
|
+
return;
|
|
1482
|
+
this.phrasebookDecls.push({ name: use.name, source: 'use', condition: use.condition, span: use.span });
|
|
1483
|
+
for (const key of manifest.keys)
|
|
1484
|
+
this.recordBookKey(key, use.condition === null);
|
|
1485
|
+
}
|
|
1486
|
+
/** `define phrasebook` (ADR-250 D1/D3) — pass 1: entry gates + coverage. */
|
|
1487
|
+
collectPhrasebook(decl) {
|
|
1488
|
+
if (!decl.name)
|
|
1489
|
+
return; // header parse error already reported
|
|
1490
|
+
if (!this.registerPhrasebookName(decl.name, decl.span))
|
|
1491
|
+
return;
|
|
1492
|
+
const entries = {};
|
|
1493
|
+
for (const entry of decl.entries) {
|
|
1494
|
+
if (entry.condition) {
|
|
1495
|
+
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);
|
|
1496
|
+
}
|
|
1497
|
+
if (entry.key.includes('.')) {
|
|
1498
|
+
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);
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
if (entry.key === 'br' || RESERVED_CHANNEL_KEYS.has(entry.key)) {
|
|
1502
|
+
this.diagnostics.error('analysis.phrasebook-reserved-key', `\`${entry.key}\` is reserved — pick another entry key.`, entry.span);
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
if (entries[entry.key]) {
|
|
1506
|
+
this.diagnostics.error('analysis.phrasebook-duplicate-key', `\`${entry.key}\` is declared twice in phrasebook \`${decl.name}\` — competing texts live in different books.`, entry.span);
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
entries[entry.key] = {
|
|
1510
|
+
strategy: entry.strategy ?? null,
|
|
1511
|
+
variants: entry.variants.map((v) => this.variantOf(v)),
|
|
1512
|
+
span: entry.span,
|
|
1513
|
+
};
|
|
1514
|
+
this.recordBookKey(entry.key, decl.condition === null);
|
|
1515
|
+
}
|
|
1516
|
+
this.phrasebookDecls.push({ name: decl.name, source: 'define', condition: decl.condition, entries, span: decl.span });
|
|
1517
|
+
}
|
|
785
1518
|
registerPhrase(locale, key, phrase) {
|
|
786
1519
|
if (key === 'br') {
|
|
787
1520
|
// `{br}` is the built-in hard-line-break marker (grammar log 2026-07-10).
|
|
@@ -816,7 +1549,16 @@ class Analyzer {
|
|
|
816
1549
|
for (const comp of decl.compositions) {
|
|
817
1550
|
const built = {
|
|
818
1551
|
name: comp.words.join(' ').toLowerCase(),
|
|
819
|
-
config: comp.config.map((c) => ({
|
|
1552
|
+
config: comp.config.map((c) => ({
|
|
1553
|
+
key: c.key.join(' '),
|
|
1554
|
+
value: c.value,
|
|
1555
|
+
valueKind: c.valueKind,
|
|
1556
|
+
// `[ … ]` list entries resolve to entity IDs here (ADR-215) —
|
|
1557
|
+
// unresolved names report through the standard unknown-entity gate.
|
|
1558
|
+
...(c.valueKind === 'list'
|
|
1559
|
+
? { values: (c.listValues ?? []).map((ref) => this.resolveEntityId(ref) ?? '').filter((id) => id !== '') }
|
|
1560
|
+
: {}),
|
|
1561
|
+
})),
|
|
820
1562
|
condition: comp.condition ? this.resolveCondition(comp.condition, entityScope(sym ?? null)) : null,
|
|
821
1563
|
span: comp.span,
|
|
822
1564
|
};
|
|
@@ -824,6 +1566,128 @@ class Analyzer {
|
|
|
824
1566
|
kinds.push(built);
|
|
825
1567
|
else
|
|
826
1568
|
traits.push(built);
|
|
1569
|
+
// ADR-215: extension vocabulary is admitted only when its `use` is
|
|
1570
|
+
// declared (core manifests — npc — are always admitted), and its
|
|
1571
|
+
// `with`-fields are the manifest's closed, typed set — unknown keys
|
|
1572
|
+
// and mistyped values are compile errors, never a silent drop at the
|
|
1573
|
+
// loader. `[ … ]` list values exist only as manifest list fields.
|
|
1574
|
+
if (!comp.article) {
|
|
1575
|
+
const contributed = (0, index_js_1.manifestForAdjective)(built.name);
|
|
1576
|
+
if (!contributed) {
|
|
1577
|
+
for (const cfg of comp.config) {
|
|
1578
|
+
if (cfg.valueKind === 'list') {
|
|
1579
|
+
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);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (contributed) {
|
|
1584
|
+
if (!contributed.manifest.core && !this.usedExtensions.has(contributed.manifest.name)) {
|
|
1585
|
+
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);
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
for (const cfg of comp.config) {
|
|
1589
|
+
const key = cfg.key.join(' ');
|
|
1590
|
+
const field = contributed.adjective.fields.find((f) => f.key === key);
|
|
1591
|
+
if (!field) {
|
|
1592
|
+
const known = contributed.adjective.fields.map((f) => f.key).join(', ');
|
|
1593
|
+
this.diagnostics.error('analysis.extension-config-key', `\`${key}\` is not a \`${built.name}\` field — known fields: ${known}.`, cfg.span);
|
|
1594
|
+
}
|
|
1595
|
+
else if (field.valueKind !== cfg.valueKind) {
|
|
1596
|
+
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);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
// ADR-231 D5a pairing gate: each `starts <state>` initializer requires
|
|
1604
|
+
// its paired trait composed on the same entity (`starts locked` needs
|
|
1605
|
+
// `lockable`, `starts closed`/`open` need `openable`, `starts off`/`on`
|
|
1606
|
+
// need `switchable`). Table-driven — STARTS_STATE_PAIRINGS is the one
|
|
1607
|
+
// place future stateful traits extend. Mismatch = load-time error, never
|
|
1608
|
+
// a silent no-op.
|
|
1609
|
+
const startsStates = [];
|
|
1610
|
+
for (const s of decl.startsStates) {
|
|
1611
|
+
const requiredTrait = catalog_js_1.STARTS_STATE_PAIRINGS.get(s.state);
|
|
1612
|
+
if (!requiredTrait)
|
|
1613
|
+
continue; // parser already rejected the word
|
|
1614
|
+
if (!traits.some((t) => t.name === requiredTrait)) {
|
|
1615
|
+
this.diagnostics.error('analysis.starts-state-pairing', `\`starts ${s.state}\` requires \`${requiredTrait}\` composed on this entity.`, s.span);
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
startsStates.push(s.state);
|
|
1619
|
+
}
|
|
1620
|
+
// ADR-242 D1: `proper` is the first kind-scoped trait adjective —
|
|
1621
|
+
// person-only and unconditional (identity is not turn state). Both
|
|
1622
|
+
// gates are analyzer diagnostics so the author reads the specific
|
|
1623
|
+
// reason, not the loader's generic conditional-composition error.
|
|
1624
|
+
const isPerson = kinds.some((k) => k.name === 'person');
|
|
1625
|
+
for (const comp of decl.compositions) {
|
|
1626
|
+
if (comp.article || comp.words.join(' ').toLowerCase() !== 'proper')
|
|
1627
|
+
continue;
|
|
1628
|
+
if (!isPerson) {
|
|
1629
|
+
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);
|
|
1630
|
+
}
|
|
1631
|
+
if (comp.condition) {
|
|
1632
|
+
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);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
// ADR-242 D5: `pronouns <word>` — person-only, at most one line, and
|
|
1636
|
+
// the word resolves against the standard four or a `define pronouns`
|
|
1637
|
+
// set (never guessed; nearest-match suggestion on a miss, ruled Q-2:
|
|
1638
|
+
// no default is injected when the line is absent).
|
|
1639
|
+
let pronouns;
|
|
1640
|
+
if (decl.pronouns.length > 0) {
|
|
1641
|
+
if (!isPerson) {
|
|
1642
|
+
this.diagnostics.error('analysis.pronouns-person-only', `\`pronouns\` is a person line — \`${decl.name.words.join(' ')}\` is not a person.`, decl.pronouns[0].span);
|
|
1643
|
+
}
|
|
1644
|
+
for (const extra of decl.pronouns.slice(1)) {
|
|
1645
|
+
this.diagnostics.error('analysis.pronouns-duplicate', 'This `create` block already has a `pronouns` line.', extra.span);
|
|
1646
|
+
}
|
|
1647
|
+
const word = decl.pronouns[0].word;
|
|
1648
|
+
if (catalog_js_1.PRONOUN_WORDS.has(word) || this.pronounSetDecls.has(word)) {
|
|
1649
|
+
if (isPerson)
|
|
1650
|
+
pronouns = word;
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
const known = [...catalog_js_1.PRONOUN_WORDS, ...this.pronounSetDecls.keys()];
|
|
1654
|
+
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);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
// Player-block composition (Gap-2 ruling, David 2026-07-18): the
|
|
1658
|
+
// player composes like any entity — but only `a person` is a legal
|
|
1659
|
+
// kind (a no-op; the player is already an actor), and NPC behavior
|
|
1660
|
+
// adjectives would hand the player to the NPC service. Both gate.
|
|
1661
|
+
if (isPlayer) {
|
|
1662
|
+
for (const kind of kinds) {
|
|
1663
|
+
if (kind.name !== 'person') {
|
|
1664
|
+
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);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
for (const trait of traits) {
|
|
1668
|
+
const contributed = (0, index_js_1.manifestForAdjective)(trait.name);
|
|
1669
|
+
if (contributed?.manifest.name === 'npc') {
|
|
1670
|
+
this.diagnostics.error('analysis.player-behavior', `\`${trait.name}\` is an NPC behavior — the player is not driven by the NPC service.`, trait.span);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
// ADR-234 D3: a door's location IS its room pair — the loader places
|
|
1675
|
+
// it in room1 per the platform convention; a placement line is a load
|
|
1676
|
+
// error (the region-placement gate is the direct precedent).
|
|
1677
|
+
if (kinds.some((k) => k.name === 'door') && decl.placement) {
|
|
1678
|
+
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);
|
|
1679
|
+
}
|
|
1680
|
+
// ADR-236 D1: a region's "location" IS its member list — placement
|
|
1681
|
+
// lines on a region block are a load error (mirror of ADR-234 D3's
|
|
1682
|
+
// door-placement gate).
|
|
1683
|
+
const isRegion = kinds.some((k) => k.name === 'region');
|
|
1684
|
+
if (isRegion && decl.placement) {
|
|
1685
|
+
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);
|
|
1686
|
+
}
|
|
1687
|
+
// ADR-236 D2: `containing` is region membership — on any other block it
|
|
1688
|
+
// would be a silent no-op, so it is a load error, never a guess.
|
|
1689
|
+
if (!isRegion && decl.containing.length > 0) {
|
|
1690
|
+
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
1691
|
}
|
|
828
1692
|
// Z1: `first time` prose compiles to RoomTrait.initialDescription —
|
|
829
1693
|
// only rooms carry that field, so any other kind is a load error
|
|
@@ -836,9 +1700,13 @@ class Analyzer {
|
|
|
836
1700
|
name: decl.name.words.join(' '),
|
|
837
1701
|
article: decl.name.article,
|
|
838
1702
|
aka: decl.aka,
|
|
1703
|
+
// Present only when declared and resolved (ruled Q-2: absent means
|
|
1704
|
+
// the platform's by-number fallback — and zero golden churn).
|
|
1705
|
+
...(pronouns !== undefined ? { pronouns } : {}),
|
|
839
1706
|
isPlayer,
|
|
840
1707
|
kinds,
|
|
841
1708
|
traits,
|
|
1709
|
+
startsStates,
|
|
842
1710
|
placement: decl.placement
|
|
843
1711
|
? {
|
|
844
1712
|
relation: decl.placement.relation,
|
|
@@ -848,7 +1716,18 @@ class Analyzer {
|
|
|
848
1716
|
: null,
|
|
849
1717
|
wears: decl.wears.map((w) => this.resolveEntityId(w) ?? '').filter((w) => w !== ''),
|
|
850
1718
|
carries: decl.carries.map((c) => this.resolveEntityId(c) ?? '').filter((c) => c !== ''),
|
|
851
|
-
|
|
1719
|
+
containing: decl.containing
|
|
1720
|
+
.map((m) => ({ id: this.resolveEntityId(m) ?? '', span: m.span }))
|
|
1721
|
+
.filter((m) => m.id !== ''),
|
|
1722
|
+
exits: decl.exits.map((e) => ({
|
|
1723
|
+
direction: e.direction,
|
|
1724
|
+
to: this.resolveEntityId(e.to) ?? '',
|
|
1725
|
+
// `through the <door>` (ADR-234 D1): resolved like any entity
|
|
1726
|
+
// reference — an unknown name is the standard unresolved-entity
|
|
1727
|
+
// error; '' marks it so checkDoors skips what is already reported.
|
|
1728
|
+
via: e.via ? (this.resolveEntityId(e.via) ?? '') : null,
|
|
1729
|
+
span: e.span,
|
|
1730
|
+
})),
|
|
852
1731
|
blockedExits: decl.blockedExits.map((b) => {
|
|
853
1732
|
this.requirePhrase(b.phraseKey, b.span);
|
|
854
1733
|
return {
|
|
@@ -860,6 +1739,14 @@ class Analyzer {
|
|
|
860
1739
|
}),
|
|
861
1740
|
deadlyExits: decl.deadlyExits.map((d) => {
|
|
862
1741
|
this.requirePhrase(d.phraseKey, d.span);
|
|
1742
|
+
// Compile gate (platform-issue-sweep Phase 8 #15d): the conditional
|
|
1743
|
+
// form is post-scope (mirror: role-bound trait clauses). It used to
|
|
1744
|
+
// fail only at LOAD (loader.ts throw — kept there as the defensive
|
|
1745
|
+
// backstop), which the harness's expect-fail-manifest convention
|
|
1746
|
+
// cannot pin; failing here makes it a compile diagnostic.
|
|
1747
|
+
if (d.condition !== null) {
|
|
1748
|
+
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);
|
|
1749
|
+
}
|
|
863
1750
|
return {
|
|
864
1751
|
direction: d.direction,
|
|
865
1752
|
phraseKey: d.phraseKey,
|
|
@@ -878,6 +1765,8 @@ class Analyzer {
|
|
|
878
1765
|
descriptionKey: decl.description ? `${id}.description` : null,
|
|
879
1766
|
initialDescriptionKey: decl.initialDescription ? `${id}.initial-description` : null,
|
|
880
1767
|
onClauses: this.checkDuplicateClauses(decl.onClauses, decl.name.words.join(' ').toLowerCase()).map((c) => this.buildOnClause(c, entityScope(sym ?? null))),
|
|
1768
|
+
// Filled by applyTopics after every entity is built (ADR-239).
|
|
1769
|
+
topics: [],
|
|
881
1770
|
span: decl.span,
|
|
882
1771
|
};
|
|
883
1772
|
}
|
|
@@ -998,8 +1887,26 @@ class Analyzer {
|
|
|
998
1887
|
span: stmt.span,
|
|
999
1888
|
};
|
|
1000
1889
|
}
|
|
1001
|
-
case 'emit':
|
|
1002
|
-
|
|
1890
|
+
case 'emit': {
|
|
1891
|
+
const payload = stmt.payload.map((f) => this.resolveEmitField(f, scope));
|
|
1892
|
+
// ADR-253 D1: record the event's top-level payload field names so a
|
|
1893
|
+
// channel `return`ing an unknown field is caught (checkChannelReturns).
|
|
1894
|
+
const eventId = stmt.event.join(' ');
|
|
1895
|
+
let fields = this.emitFields.get(eventId);
|
|
1896
|
+
if (!fields) {
|
|
1897
|
+
fields = new Set();
|
|
1898
|
+
this.emitFields.set(eventId, fields);
|
|
1899
|
+
}
|
|
1900
|
+
for (const f of stmt.payload)
|
|
1901
|
+
fields.add(f.key.join(' '));
|
|
1902
|
+
return {
|
|
1903
|
+
kind: 'emit',
|
|
1904
|
+
event: eventId,
|
|
1905
|
+
...(payload.length > 0 ? { payload } : {}),
|
|
1906
|
+
stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope),
|
|
1907
|
+
span: stmt.span,
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1003
1910
|
case 'set':
|
|
1004
1911
|
return {
|
|
1005
1912
|
kind: 'set',
|
|
@@ -1098,6 +2005,8 @@ class Analyzer {
|
|
|
1098
2005
|
span: stmt.span,
|
|
1099
2006
|
};
|
|
1100
2007
|
}
|
|
2008
|
+
case 'media':
|
|
2009
|
+
return this.lowerMediaStatement(stmt, scope);
|
|
1101
2010
|
case 'select-on': {
|
|
1102
2011
|
const subject = this.resolveValue(stmt.subject, scope);
|
|
1103
2012
|
const stateOwner = this.stateOwnerOf(subject, scope);
|
|
@@ -1204,6 +2113,230 @@ class Analyzer {
|
|
|
1204
2113
|
return this.resolveMatch(expr.span, scope);
|
|
1205
2114
|
}
|
|
1206
2115
|
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Lower one media sugar statement (ADR-216) onto a payloaded `media-*`
|
|
2118
|
+
* emit — pure compile-time sugar, no runtime surface of its own. The IR
|
|
2119
|
+
* event id is the dotless Chord form (ADR-254/256); `@sharpee/story-loader`
|
|
2120
|
+
* translates it to the platform's dotted `media.*` id at the emit seam.
|
|
2121
|
+
* Asset references are typo-checked with a nearest-match suggestion; kind
|
|
2122
|
+
* mismatches gate (`play ambient` plays SOUND assets — an ambient loop
|
|
2123
|
+
* is a sound file).
|
|
2124
|
+
*/
|
|
2125
|
+
lowerMediaStatement(stmt, scope) {
|
|
2126
|
+
const stmtWhen = this.resolveStmtWhen(stmt.stmtWhen, scope);
|
|
2127
|
+
const fields = [];
|
|
2128
|
+
let event = '';
|
|
2129
|
+
const requireAsset = (expected) => {
|
|
2130
|
+
if (!stmt.asset)
|
|
2131
|
+
return; // the parser already reported
|
|
2132
|
+
const found = this.assets.get(stmt.asset);
|
|
2133
|
+
if (!found) {
|
|
2134
|
+
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);
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
if (found.kind !== expected) {
|
|
2138
|
+
this.diagnostics.error('analysis.asset-kind', `\`${stmt.asset}\` is a ${found.kind} asset — this statement needs a ${expected} asset.`, stmt.span);
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
fields.push({ key: 'src', value: { kind: 'literal', value: found.path, valueType: 'string' } });
|
|
2142
|
+
};
|
|
2143
|
+
switch (stmt.form) {
|
|
2144
|
+
case 'play-sound':
|
|
2145
|
+
event = 'media-sound-play';
|
|
2146
|
+
requireAsset('sound');
|
|
2147
|
+
break;
|
|
2148
|
+
case 'play-music':
|
|
2149
|
+
event = 'media-music-play';
|
|
2150
|
+
requireAsset('music');
|
|
2151
|
+
if (stmt.looping)
|
|
2152
|
+
fields.push({ key: 'loop', value: { kind: 'value', value: { kind: 'symbol', name: 'true' } } });
|
|
2153
|
+
break;
|
|
2154
|
+
case 'stop-music':
|
|
2155
|
+
event = 'media-music-stop';
|
|
2156
|
+
break;
|
|
2157
|
+
case 'play-ambient':
|
|
2158
|
+
event = 'media-ambient-play';
|
|
2159
|
+
requireAsset('sound');
|
|
2160
|
+
this.stampAmbientChannel(stmt, fields);
|
|
2161
|
+
break;
|
|
2162
|
+
case 'stop-ambient':
|
|
2163
|
+
event = 'media-ambient-stop';
|
|
2164
|
+
this.stampAmbientChannel(stmt, fields);
|
|
2165
|
+
break;
|
|
2166
|
+
case 'show-image':
|
|
2167
|
+
event = 'media-image-show';
|
|
2168
|
+
requireAsset('image');
|
|
2169
|
+
if (stmt.layer) {
|
|
2170
|
+
// ADR-241 D3: layers beyond the platform's pre-registered three
|
|
2171
|
+
// must be declared (`define layer <word>`) — never-guess.
|
|
2172
|
+
if (!IMPLIED_IMAGE_LAYERS.has(stmt.layer) && !this.familyChannels.layer.has(stmt.layer)) {
|
|
2173
|
+
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);
|
|
2174
|
+
}
|
|
2175
|
+
fields.push({ key: 'layer', value: { kind: 'literal', value: stmt.layer, valueType: 'string' } });
|
|
2176
|
+
}
|
|
2177
|
+
break;
|
|
2178
|
+
case 'hide-image':
|
|
2179
|
+
event = 'media-image-hide';
|
|
2180
|
+
break;
|
|
2181
|
+
case 'transition':
|
|
2182
|
+
event = 'media-transition';
|
|
2183
|
+
fields.push({ key: 'kind', value: { kind: 'literal', value: stmt.transitionKind ?? '', valueType: 'string' } });
|
|
2184
|
+
break;
|
|
2185
|
+
case 'clear':
|
|
2186
|
+
event = 'media-clear';
|
|
2187
|
+
break;
|
|
2188
|
+
}
|
|
2189
|
+
return { kind: 'emit', event, ...(fields.length > 0 ? { payload: fields } : {}), stmtWhen, span: stmt.span };
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* ADR-241 D3: resolve an ambient statement's channel word (the default
|
|
2193
|
+
* bed is `main` — Q-1), gate undeclared words (never-guess — Q-3), and
|
|
2194
|
+
* stamp `channel` onto the payload — the field stdlib's ambient
|
|
2195
|
+
* channels filter on. A bare use of the implied `main` bed records its
|
|
2196
|
+
* first use-site so the bed joins the channel manifest (D4).
|
|
2197
|
+
*/
|
|
2198
|
+
stampAmbientChannel(stmt, fields) {
|
|
2199
|
+
const word = stmt.channel ?? 'main';
|
|
2200
|
+
if (word === 'main') {
|
|
2201
|
+
if (!this.familyChannels.ambient.has('main'))
|
|
2202
|
+
this.impliedMainBedSpan ??= stmt.span;
|
|
2203
|
+
}
|
|
2204
|
+
else if (!this.familyChannels.ambient.has(word)) {
|
|
2205
|
+
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);
|
|
2206
|
+
}
|
|
2207
|
+
fields.push({ key: 'channel', value: { kind: 'literal', value: word, valueType: 'string' } });
|
|
2208
|
+
}
|
|
2209
|
+
/** Channel names seen (duplicate gate). */
|
|
2210
|
+
channelNames = new Set();
|
|
2211
|
+
/** Declared family channels (ADR-241 D2) by family: word → first declaration span. */
|
|
2212
|
+
familyChannels = {
|
|
2213
|
+
ambient: new Map(),
|
|
2214
|
+
layer: new Map(),
|
|
2215
|
+
};
|
|
2216
|
+
/** First use-site of the implied `main` ambient bed (ADR-241 D3), if any. */
|
|
2217
|
+
impliedMainBedSpan = null;
|
|
2218
|
+
/**
|
|
2219
|
+
* Build one `define channel` (ADR-216; spelling A): mode/from/take are
|
|
2220
|
+
* required, `gated by` must name a client capability flag (Chord
|
|
2221
|
+
* spelling, lowered to the platform's camelCase key). Re-declaring a
|
|
2222
|
+
* STANDARD channel id is legal platform behavior (story override);
|
|
2223
|
+
* duplicating a story channel is not.
|
|
2224
|
+
*/
|
|
2225
|
+
buildChannel(decl) {
|
|
2226
|
+
if (this.channelNames.has(decl.name)) {
|
|
2227
|
+
this.diagnostics.error('analysis.duplicate-channel', `A channel named \`${decl.name}\` already exists.`, decl.span);
|
|
2228
|
+
}
|
|
2229
|
+
this.channelNames.add(decl.name);
|
|
2230
|
+
if (decl.mode === null || !['replace', 'append', 'event'].includes(decl.mode)) {
|
|
2231
|
+
this.diagnostics.error('analysis.channel-mode', `Channel \`${decl.name}\` needs \`mode replace\`, \`mode append\`, or \`mode event\`${decl.mode ? ` — got \`${decl.mode}\`` : ''}.`, decl.span);
|
|
2232
|
+
}
|
|
2233
|
+
if (decl.returns === null || decl.fromEvent === null) {
|
|
2234
|
+
this.diagnostics.error('analysis.channel-return', `Channel \`${decl.name}\` needs a \`return <field | "text" | phrase <key>> from <event>\` line.`, decl.span);
|
|
2235
|
+
}
|
|
2236
|
+
let gatedBy = null;
|
|
2237
|
+
if (decl.gatedBy !== null) {
|
|
2238
|
+
if (!catalog_js_1.CLIENT_CAPABILITY_FLAGS.has(decl.gatedBy)) {
|
|
2239
|
+
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);
|
|
2240
|
+
}
|
|
2241
|
+
else {
|
|
2242
|
+
gatedBy = (0, catalog_js_1.capabilityKeyOf)(decl.gatedBy);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
return {
|
|
2246
|
+
name: decl.name,
|
|
2247
|
+
family: 'data',
|
|
2248
|
+
mode: (decl.mode ?? 'event'),
|
|
2249
|
+
gatedBy,
|
|
2250
|
+
fromEvent: decl.fromEvent ?? '',
|
|
2251
|
+
returns: decl.returns ?? { kind: 'field', field: '' },
|
|
2252
|
+
span: decl.span,
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* ADR-253 D1 field check: a channel returning a `field` (or a `text` template
|
|
2257
|
+
* with `(slot)` names) references event payload fields; error when the named
|
|
2258
|
+
* event emits a payload that lacks one. Runs as a post-pass so every `emit`
|
|
2259
|
+
* is collected first, regardless of declaration order. An event with no
|
|
2260
|
+
* collected payload (platform events, or one never emitted here) is skipped —
|
|
2261
|
+
* its field set is unknown, not empty. `phrase` returns own their slots
|
|
2262
|
+
* (the phrase system), so they are not cross-checked here.
|
|
2263
|
+
*/
|
|
2264
|
+
checkChannelReturns(channels) {
|
|
2265
|
+
for (const ch of channels) {
|
|
2266
|
+
if (ch.family !== 'data')
|
|
2267
|
+
continue;
|
|
2268
|
+
const emitted = this.emitFields.get(ch.fromEvent);
|
|
2269
|
+
if (!emitted)
|
|
2270
|
+
continue; // unknown field set — cannot check
|
|
2271
|
+
const required = ch.returns.kind === 'field'
|
|
2272
|
+
? [ch.returns.field]
|
|
2273
|
+
: ch.returns.kind === 'text'
|
|
2274
|
+
? [...ch.returns.text.matchAll(/\(\s*([^)]+?)\s*\)/g)].map((m) => m[1])
|
|
2275
|
+
: [];
|
|
2276
|
+
for (const field of required) {
|
|
2277
|
+
if (!emitted.has(field)) {
|
|
2278
|
+
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);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* `define pronouns` (ADR-242 D7): exactly the five case rows the
|
|
2285
|
+
* assembler's pronoun table keys — a missing or duplicate row is an
|
|
2286
|
+
* error (order is free; named rows, ruled Q-1). Forms pass through as
|
|
2287
|
+
* data; the language provider owns rendering them.
|
|
2288
|
+
*/
|
|
2289
|
+
buildPronounSet(decl) {
|
|
2290
|
+
const byCase = new Map();
|
|
2291
|
+
for (const row of decl.rows) {
|
|
2292
|
+
if (byCase.has(row.case)) {
|
|
2293
|
+
this.diagnostics.error('analysis.pronoun-set-duplicate-row', `\`define pronouns ${decl.name}\` already has a \`${row.case}\` row.`, row.span);
|
|
2294
|
+
continue;
|
|
2295
|
+
}
|
|
2296
|
+
byCase.set(row.case, row.form);
|
|
2297
|
+
}
|
|
2298
|
+
for (const c of catalog_js_1.PRONOUN_CASES) {
|
|
2299
|
+
if (!byCase.has(c)) {
|
|
2300
|
+
this.diagnostics.error('analysis.pronoun-set-rows', `\`define pronouns ${decl.name}\` is missing its \`${c}\` row — all five case rows are required.`, decl.span);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return {
|
|
2304
|
+
name: decl.name,
|
|
2305
|
+
forms: {
|
|
2306
|
+
subject: byCase.get('subject') ?? '',
|
|
2307
|
+
object: byCase.get('object') ?? '',
|
|
2308
|
+
possessive: byCase.get('possessive') ?? '',
|
|
2309
|
+
possessivePronoun: byCase.get('possessive-pronoun') ?? '',
|
|
2310
|
+
reflexive: byCase.get('reflexive') ?? '',
|
|
2311
|
+
},
|
|
2312
|
+
span: decl.span,
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
/** Resolve one emit-payload field (ADR-216) — key words join with a space, passed verbatim. */
|
|
2316
|
+
resolveEmitField(field, scope) {
|
|
2317
|
+
return { key: field.key.join(' '), value: this.resolveEmitValue(field.value, scope) };
|
|
2318
|
+
}
|
|
2319
|
+
/** Resolve one emit-payload value (ADR-216) — recursive over arrays/objects. */
|
|
2320
|
+
resolveEmitValue(value, scope) {
|
|
2321
|
+
switch (value.kind) {
|
|
2322
|
+
case 'literal':
|
|
2323
|
+
return { kind: 'literal', value: value.value, valueType: value.literalKind };
|
|
2324
|
+
case 'expr':
|
|
2325
|
+
return { kind: 'value', value: this.resolveValue(value.expr, scope) };
|
|
2326
|
+
case 'array':
|
|
2327
|
+
return { kind: 'array', items: value.items.map((i) => this.resolveEmitValue(i, scope)) };
|
|
2328
|
+
case 'object':
|
|
2329
|
+
return { kind: 'object', fields: value.fields.map((f) => this.resolveEmitField(f, scope)) };
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* `it` in a story-owned clause (ADR-236 D7): the story is the owner and
|
|
2334
|
+
* has no entity referent — the unbound-referent case no other clause
|
|
2335
|
+
* home can produce. A load error, never a silent undefined.
|
|
2336
|
+
*/
|
|
2337
|
+
reportStoryClauseIt(span) {
|
|
2338
|
+
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);
|
|
2339
|
+
}
|
|
1207
2340
|
/**
|
|
1208
2341
|
* `the match` — the `each`-block binder (ratchet E3). Legal only inside
|
|
1209
2342
|
* an `each` body, at any nesting depth (the runtime binds innermost).
|
|
@@ -1240,8 +2373,11 @@ class Analyzer {
|
|
|
1240
2373
|
}
|
|
1241
2374
|
resolveRefValue(ref, scope) {
|
|
1242
2375
|
const words = ref.words.map((w) => w.toLowerCase());
|
|
1243
|
-
if (words.length === 1 && words[0] === 'it')
|
|
2376
|
+
if (words.length === 1 && words[0] === 'it') {
|
|
2377
|
+
if (scope.storyOwned)
|
|
2378
|
+
this.reportStoryClauseIt(ref.span);
|
|
1244
2379
|
return { kind: 'it' };
|
|
2380
|
+
}
|
|
1245
2381
|
// `the match` in NameRef positions (`change`/`move` targets, predicate
|
|
1246
2382
|
// things) resolves to the binder exactly as `it` does — before entity
|
|
1247
2383
|
// lookup; the name itself is reserved at declaration (E3/P3).
|
|
@@ -1255,6 +2391,8 @@ class Analyzer {
|
|
|
1255
2391
|
}
|
|
1256
2392
|
// `its <field>` in name position (`the actor has its food`).
|
|
1257
2393
|
if (words.length > 1 && words[0] === 'its') {
|
|
2394
|
+
if (scope.storyOwned)
|
|
2395
|
+
this.reportStoryClauseIt(ref.span);
|
|
1258
2396
|
return { kind: 'field', base: { kind: 'it' }, field: words.slice(1).join(' ') };
|
|
1259
2397
|
}
|
|
1260
2398
|
const scoped = this.resolveScopedWords(ref.words, scope);
|
|
@@ -1320,6 +2458,14 @@ class Analyzer {
|
|
|
1320
2458
|
return { kind: 'not', operand: this.resolveCondition(cond.operand, scope) };
|
|
1321
2459
|
case 'chance':
|
|
1322
2460
|
return { kind: 'chance', n: cond.n };
|
|
2461
|
+
case 'client-has': {
|
|
2462
|
+
// ADR-216: capability words are the closed platform flag set —
|
|
2463
|
+
// validated here, lowered to the camelCase platform key.
|
|
2464
|
+
if (!catalog_js_1.CLIENT_CAPABILITY_FLAGS.has(cond.capability)) {
|
|
2465
|
+
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);
|
|
2466
|
+
}
|
|
2467
|
+
return { kind: 'client-has', capability: (0, catalog_js_1.capabilityKeyOf)(cond.capability) };
|
|
2468
|
+
}
|
|
1323
2469
|
case 'condition-ref': {
|
|
1324
2470
|
if (this.conditionNames.has(cond.name)) {
|
|
1325
2471
|
// Never-guess gate (grammar log 2026-07-11): an OPEN condition is a
|
|
@@ -1462,15 +2608,15 @@ class Analyzer {
|
|
|
1462
2608
|
const validStates = subjectEntity?.states ?? (subject.kind === 'it' ? scope.ownStates ?? [] : []);
|
|
1463
2609
|
if (validStates.includes(word))
|
|
1464
2610
|
return { kind: 'symbol', name: word };
|
|
1465
|
-
if (
|
|
2611
|
+
if (catalog_js_1.TRAIT_ADJECTIVES.has(word))
|
|
1466
2612
|
return { kind: 'symbol', name: word };
|
|
1467
2613
|
// State adjectives (ratchet D1): read live from world trait state.
|
|
1468
|
-
if (
|
|
2614
|
+
if (catalog_js_1.STATE_ADJECTIVES.has(word))
|
|
1469
2615
|
return { kind: 'symbol', name: word };
|
|
1470
2616
|
const exactEntity = this.entities.find((e) => e.nameLower === word.toLowerCase() || e.aka.includes(word.toLowerCase()));
|
|
1471
2617
|
if (exactEntity)
|
|
1472
2618
|
return { kind: 'entity', id: exactEntity.id };
|
|
1473
|
-
const valid = [...validStates, ...
|
|
2619
|
+
const valid = [...validStates, ...catalog_js_1.TRAIT_ADJECTIVES, ...catalog_js_1.STATE_ADJECTIVES];
|
|
1474
2620
|
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
2621
|
return { kind: 'symbol', name: word };
|
|
1476
2622
|
}
|
|
@@ -1495,6 +2641,15 @@ class Analyzer {
|
|
|
1495
2641
|
return;
|
|
1496
2642
|
if (owner && table?.has(`${owner.id}.${key}`))
|
|
1497
2643
|
return;
|
|
2644
|
+
// ADR-250 D4.6: book coverage counts as declaration. Covered only by
|
|
2645
|
+
// predicated books → warn once (off-book it renders nothing).
|
|
2646
|
+
if (this.bookKeys.has(key)) {
|
|
2647
|
+
if (!this.bookKeys.get(key) && !this.partialCoverageWarned.has(key)) {
|
|
2648
|
+
this.partialCoverageWarned.add(key);
|
|
2649
|
+
this.diagnostics.warning('analysis.phrasebook-partial-coverage', `\`${key}\` is only defined in conditional phrasebooks — when no book is active it renders nothing.`, span);
|
|
2650
|
+
}
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
1498
2653
|
const known = table ? [...table.keys()] : [];
|
|
1499
2654
|
this.diagnostics.error('analysis.missing-phrase', `Phrase \`${key}\` is not declared in ${DEFAULT_LOCALE}${this.suggestText(key, known)}.`, span);
|
|
1500
2655
|
}
|
|
@@ -1506,26 +2661,40 @@ class Analyzer {
|
|
|
1506
2661
|
checkMarkers() {
|
|
1507
2662
|
for (const [, table] of this.phrases) {
|
|
1508
2663
|
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
|
-
|
|
2664
|
+
this.checkPhraseMarkers(key, phrase);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
// Phrasebook entries carry the same marker contract (ADR-250 D1 —
|
|
2668
|
+
// entries are ordinary phrase definitions).
|
|
2669
|
+
for (const book of this.phrasebookDecls) {
|
|
2670
|
+
if (!book.entries)
|
|
2671
|
+
continue;
|
|
2672
|
+
for (const [key, phrase] of Object.entries(book.entries)) {
|
|
2673
|
+
this.checkPhraseMarkers(`${book.name}.${key}`, phrase);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
checkPhraseMarkers(label, phrase) {
|
|
2678
|
+
for (const variant of phrase.variants) {
|
|
2679
|
+
// A variant carrying formatter-chain forms ({You}, {the item},
|
|
2680
|
+
// {verb:…}) is a TEMPLATE — its bare lowercase markers are chain
|
|
2681
|
+
// verbs ({open}), not producer references (Phase B: §3.2 trait
|
|
2682
|
+
// phrases). Full chain validation lands with the AC-9 contract.
|
|
2683
|
+
const isTemplate = variant.markers.some((m) => /[A-Z]/.test(m[0] ?? '') || m.includes(' ') || m.includes(':'));
|
|
2684
|
+
if (isTemplate)
|
|
2685
|
+
continue;
|
|
2686
|
+
for (const marker of variant.markers) {
|
|
2687
|
+
if (!/^[a-z][a-z0-9-]*$/.test(marker))
|
|
2688
|
+
continue;
|
|
2689
|
+
if (marker === 'br')
|
|
2690
|
+
continue; // built-in line break (grammar log 2026-07-10)
|
|
2691
|
+
if (this.hatchNames.has(marker))
|
|
2692
|
+
continue;
|
|
2693
|
+
if (this.phrases.get(DEFAULT_LOCALE)?.has(marker))
|
|
2694
|
+
continue;
|
|
2695
|
+
if (this.bookKeys.has(marker))
|
|
2696
|
+
continue; // book coverage counts (ADR-250 D4.6)
|
|
2697
|
+
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
2698
|
}
|
|
1530
2699
|
}
|
|
1531
2700
|
}
|