@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.
Files changed (68) hide show
  1. package/analyzer.d.ts +11 -3
  2. package/analyzer.d.ts.map +1 -1
  3. package/analyzer.js +1360 -33
  4. package/analyzer.js.map +1 -1
  5. package/ast.d.ts +480 -9
  6. package/ast.d.ts.map +1 -1
  7. package/catalog.d.ts +57 -1
  8. package/catalog.d.ts.map +1 -1
  9. package/catalog.js +91 -3
  10. package/catalog.js.map +1 -1
  11. package/diagnostics.d.ts +1 -1
  12. package/diagnostics.d.ts.map +1 -1
  13. package/index.d.ts +32 -13
  14. package/index.d.ts.map +1 -1
  15. package/index.js +112 -30
  16. package/index.js.map +1 -1
  17. package/ir.d.ts +361 -7
  18. package/ir.d.ts.map +1 -1
  19. package/lexer.d.ts +11 -3
  20. package/lexer.d.ts.map +1 -1
  21. package/lexer.js +36 -8
  22. package/lexer.js.map +1 -1
  23. package/manifests/combat.d.ts +16 -0
  24. package/manifests/combat.d.ts.map +1 -0
  25. package/manifests/combat.js +38 -0
  26. package/manifests/combat.js.map +1 -0
  27. package/manifests/hunger.d.ts +18 -0
  28. package/manifests/hunger.d.ts.map +1 -0
  29. package/manifests/hunger.js +8 -0
  30. package/manifests/hunger.js.map +1 -0
  31. package/manifests/index.d.ts +23 -0
  32. package/manifests/index.d.ts.map +1 -0
  33. package/manifests/index.js +52 -0
  34. package/manifests/index.js.map +1 -0
  35. package/manifests/npc.d.ts +19 -0
  36. package/manifests/npc.d.ts.map +1 -0
  37. package/manifests/npc.js +38 -0
  38. package/manifests/npc.js.map +1 -0
  39. package/manifests/scoring.d.ts +25 -0
  40. package/manifests/scoring.d.ts.map +1 -0
  41. package/manifests/scoring.js +8 -0
  42. package/manifests/scoring.js.map +1 -0
  43. package/manifests/state-machines.d.ts +17 -0
  44. package/manifests/state-machines.d.ts.map +1 -0
  45. package/manifests/state-machines.js +8 -0
  46. package/manifests/state-machines.js.map +1 -0
  47. package/manifests/types.d.ts +41 -0
  48. package/manifests/types.d.ts.map +1 -0
  49. package/manifests/types.js +18 -0
  50. package/manifests/types.js.map +1 -0
  51. package/message-alias-catalog.d.ts +22 -0
  52. package/message-alias-catalog.d.ts.map +1 -0
  53. package/message-alias-catalog.js +826 -0
  54. package/message-alias-catalog.js.map +1 -0
  55. package/package.json +1 -1
  56. package/parser.d.ts +2 -2
  57. package/parser.d.ts.map +1 -1
  58. package/parser.js +1644 -131
  59. package/parser.js.map +1 -1
  60. package/phrasebooks.d.ts +28 -0
  61. package/phrasebooks.d.ts.map +1 -0
  62. package/phrasebooks.js +6 -0
  63. package/phrasebooks.js.map +1 -0
  64. package/version.d.ts +38 -0
  65. package/version.d.ts.map +1 -0
  66. package/version.js +41 -0
  67. package/version.js.map +1 -0
  68. 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 catalog_1 = require("./catalog");
5
- const ir_1 = require("./ir");
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: ir_1.IR_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 (catalog_1.EVENT_VERBS.has(clause.action)) {
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 catalog_1.PLATFORM_STATE_PAIRS) {
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) => ({ key: c.key.join(' '), value: c.value, valueKind: c.valueKind })),
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
- exits: decl.exits.map((e) => ({ direction: e.direction, to: this.resolveEntityId(e.to) ?? '', span: e.span })),
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
- return { kind: 'emit', event: stmt.event.join(' '), stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
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 (catalog_1.TRAIT_ADJECTIVES.has(word))
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 (catalog_1.STATE_ADJECTIVES.has(word))
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, ...catalog_1.TRAIT_ADJECTIVES, ...catalog_1.STATE_ADJECTIVES];
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
- for (const variant of phrase.variants) {
1510
- // A variant carrying formatter-chain forms ({You}, {the item},
1511
- // {verb:…}) is a TEMPLATE — its bare lowercase markers are chain
1512
- // verbs ({open}), not producer references (Phase B: §3.2 trait
1513
- // phrases). Full chain validation lands with the AC-9 contract.
1514
- const isTemplate = variant.markers.some((m) => /[A-Z]/.test(m[0] ?? '') || m.includes(' ') || m.includes(':'));
1515
- if (isTemplate)
1516
- continue;
1517
- for (const marker of variant.markers) {
1518
- if (!/^[a-z][a-z0-9-]*$/.test(marker))
1519
- continue;
1520
- if (marker === 'br')
1521
- continue; // built-in line break (grammar log 2026-07-10)
1522
- if (this.hatchNames.has(marker))
1523
- continue;
1524
- if (this.phrases.get(DEFAULT_LOCALE)?.has(marker))
1525
- continue;
1526
- this.diagnostics.error('analysis.unbound-marker', `\`{${marker}}\` in phrase \`${key}\` is not a declared text producer or phrase${this.suggestText(marker, [...this.hatchNames])}.`, phrase.span);
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
  }