@sharpee/chord 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/analyzer.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../../../repos/sharpee_v2/packages/chord/src/analyzer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAsBL,SAAS,EAKV,MAAM,UAAU,CAAC;AAIlB,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAoBL,OAAO,EACR,MAAM,SAAS,CAAC;AAyCjB;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAInD;AA8LD;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,GAAG,OAAO,CAE3E"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../../../repos/sharpee_v2/packages/chord/src/analyzer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAsBL,SAAS,EAKV,MAAM,UAAU,CAAC;AAIlB,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,OAAO,EAuBL,OAAO,EACR,MAAM,SAAS,CAAC;AAsDjB;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAInD;AA8LD;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,GAAG,OAAO,CAE3E"}
package/analyzer.js CHANGED
@@ -5,9 +5,22 @@ exports.analyze = analyze;
5
5
  const catalog_js_1 = require("./catalog.js");
6
6
  const index_js_1 = require("./manifests/index.js");
7
7
  const phrasebooks_js_1 = require("./phrasebooks.js");
8
+ const version_js_1 = require("./version.js");
8
9
  const ir_js_1 = require("./ir.js");
9
10
  /** Phase A stories register text in this locale (design.md §2.6). */
10
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
+ }
11
24
  /**
12
25
  * Exit-direction opposites (parser DIRECTIONS vocabulary) — the same
13
26
  * inference every plain exit line performs platform-side; here it backs
@@ -293,7 +306,18 @@ class Analyzer {
293
306
  // ADR-215: validate `use` lines against the manifest registry — an
294
307
  // unknown name is a compile error (the loader's trusted registry check
295
308
  // backstops rogue IR), a duplicate is one-`use`-per-extension.
309
+ const announceModes = {};
310
+ const VALID_ANNOUNCE_MODES = ['all', 'collapsed', 'combined', 'silent'];
296
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
+ }
297
321
  const manifest = index_js_1.EXTENSION_MANIFESTS.get(use.name);
298
322
  if (!manifest) {
299
323
  const gated = [...index_js_1.EXTENSION_MANIFESTS.values()].filter((m) => !m.core).map((m) => m.name);
@@ -310,14 +334,27 @@ class Analyzer {
310
334
  this.usedExtensions.add(use.name);
311
335
  }
312
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();
313
348
  const ir = {
314
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
315
351
  meta: {
316
352
  title: this.ast.header?.title ?? '',
317
353
  author: this.ast.header?.author ?? '',
318
354
  fields: this.ast.header?.fields ?? {},
319
355
  },
320
356
  uses: [...this.usedExtensions],
357
+ announceModes,
321
358
  story: {
322
359
  states: this.storyStates,
323
360
  reversible: this.ast.header?.statesReversible ?? false,
@@ -339,6 +376,8 @@ class Analyzer {
339
376
  traits: [],
340
377
  actions: [],
341
378
  scores: this.scoreDecls,
379
+ ranks: this.buildRanks(),
380
+ ...(hungerDef !== undefined ? { hunger: hungerDef } : {}),
342
381
  sequences: [],
343
382
  machines: [],
344
383
  channels: [],
@@ -1383,6 +1422,121 @@ class Analyzer {
1383
1422
  for (const s of decl.scores)
1384
1423
  this.collectScore(s.name, s.worth, s.span, id);
1385
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
+ }
1386
1540
  /** Register an owner-attached score (ratchet D12) under its qualified id. */
1387
1541
  collectScore(name, worth, span, ownerKey) {
1388
1542
  const qualified = ownerKey ? `${ownerKey}.${name}` : name;
@@ -1962,6 +2116,10 @@ class Analyzer {
1962
2116
  span: stmt.span,
1963
2117
  };
1964
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
+ }
1965
2123
  // `award <score-name>` resolves owner-first (ratchet D12): the
1966
2124
  // enclosing owner's qualified id, then the story-level bare name.
1967
2125
  let expression = stmt.expression;