@heroiclands/package-build 23.2.2 → 23.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ **New npcs**
8
+
9
+ - `package-build npc new` writes a complete npc note from a culture and a gender, filling every other field, an Appearance paragraph and a name; the same spec always gives the same person, and `--seed` draws another.
10
+ - Give any field yourself, inline or from a JSON or YAML file, including the name and the Appearance and Dossier text, and it is written exactly as you gave it.
11
+ - A culture describes its people in `data.appearanceProfile`, with weighted eye, hair and skin colours, complexions, frames and height ranges, and names the tongue they speak in `data.language`.
12
+ - A language skill note lists its speakers' names in `data.names`: one or several patterns such as `{given} {clan}` or `{given} {given.male}sen`, by gender where forms differ, and the lists they draw on; a name you give must fit one.
13
+ - `package-build lint` checks a profile's words, weights and ranges, and a language's pattern and lists.
14
+
15
+ ## 23.3.0
16
+
17
+ ### Minor Changes
18
+
19
+ **Being notes**
20
+
21
+ - A being's eye, hair and skin colour and its complexion now take words from a fixed list, alongside gender and frame, and any other value fails `package-build lint` naming the accepted words.
22
+ - Variant spellings and empty values are refused too: write `grey` rather than `gray`, and leave a field out when it is unrecorded.
23
+ - A complexion takes one word or a list of them; distinguishing marks stay free text in `extra_features`.
24
+ - The format reference lists every word with what it means.
25
+
26
+ ### Patch Changes
27
+
28
+ A region can list its settlements in a table under `## Settlements` — name, market, people and holder, one row each — tagged with how its countryside is held: `held`, `endowed`, `registered`, `kin` or `polis`. `package-build lint` checks the table's columns, its market classes and figures, that a holder suits the region's model, and that every linked row agrees with the settlement's own note.
29
+
30
+ `e2e` now says when the Foundry licence is unsigned, and how to sign it, instead of timing out waiting for a world.
31
+
3
32
  ## 23.2.2
4
33
 
5
34
  ### Patch Changes
@@ -31,6 +31,7 @@
31
31
  * npx package-build map [--tree] [--from <shortcode>] [--chart <shortcode>] [--travel] [--out <dir>]
32
32
  * npx package-build reachability <dir> [file] [--index <shortcode>]
33
33
  * npx package-build addresses diff --from <zip|dir> [--strict]
34
+ * npx package-build npc new (--spec <json> | --specfile <path>) [--seed <text>] [--out <path>]
34
35
  *
35
36
  * In a consuming repository, wrapped as npm scripts — SoHL spells them:
36
37
  * npm run build:compiledb // → … package compile (all packs)
@@ -112,7 +113,12 @@ import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-
112
113
  import { prepareTreeSqlTables } from "../engine/sql-tables.mjs";
113
114
  // The one place a link finding is worded, shared with both builds.
114
115
  import { linkFindingMessage } from "../engine/wikilink-syntax.mjs";
115
- import { emitDiagnostic, positionOfLiteral } from "../engine/diagnostics.mjs";
116
+ import {
117
+ emitDiagnostic,
118
+ positionOfFrontmatterPath,
119
+ positionOfLiteral,
120
+ positionOfYamlPath,
121
+ } from "../engine/diagnostics.mjs";
116
122
  import { reportFindings } from "./report.mjs";
117
123
  import {
118
124
  readItemAddresses,
@@ -127,6 +133,14 @@ import { CHART_HORIZON_DAYS } from "../engine/map-layout.mjs";
127
133
  import { GRAPHVIZ_ENGINES } from "../engine/map-graphviz.mjs";
128
134
  import { loadMapWorld } from "../engine/map-places.mjs";
129
135
  import { encodeAddresses } from "../engine/address-values.mjs";
136
+ import YAML from "yaml";
137
+ import { worldInvariants } from "../engine/calendar-notes.mjs";
138
+ import { reckoningContext } from "../engine/reckoning-markers.mjs";
139
+ import { BEING_LOOK_VOCABULARY } from "../engine/note-vocabulary.mjs";
140
+ import { profileFindings } from "../engine/appearance-profile.mjs";
141
+ import { namesFindings } from "../engine/language-names.mjs";
142
+ import { parseAddress, renderAddress } from "../engine/address.mjs";
143
+ import { cultureKey, generateNpc, npcDefaults, npcSpecProblems } from "../engine/npc-generate.mjs";
130
144
 
131
145
  /**
132
146
  * The packs `unpack` extracts.
@@ -241,7 +255,8 @@ export function registerContentCommands(cli) {
241
255
  )
242
256
  .command(withIndexPreflight(mapCommand()))
243
257
  .command(withIndexPreflight(reachabilityCommand()))
244
- .command(addressesCommand());
258
+ .command(addressesCommand())
259
+ .command(withIndexPreflight(npcCommand()));
245
260
  }
246
261
 
247
262
  /** Optional sentence findings and note-level readability reports. */
@@ -490,6 +505,12 @@ function docsCommand() {
490
505
  type: "string",
491
506
  default: "docs",
492
507
  });
508
+ yargs.option("seed", {
509
+ describe:
510
+ "Text that seeds generation beside the spec, to draw a different person " +
511
+ "from the same spec.",
512
+ type: "string",
513
+ });
493
514
  yargs.option("out", {
494
515
  describe: "Write to this file instead of the configured location.",
495
516
  type: "string",
@@ -1555,6 +1576,12 @@ function contentIndexCommand() {
1555
1576
  describe: "Content tree to read. Defaults to the configured contentBase.",
1556
1577
  type: "string",
1557
1578
  });
1579
+ yargs.option("seed", {
1580
+ describe:
1581
+ "Text that seeds generation beside the spec, to draw a different person " +
1582
+ "from the same spec.",
1583
+ type: "string",
1584
+ });
1558
1585
  yargs.option("out", {
1559
1586
  describe:
1560
1587
  "Directory to write into. Defaults to the configured " +
@@ -2515,3 +2542,267 @@ function packageCommand() {
2515
2542
  },
2516
2543
  };
2517
2544
  }
2545
+
2546
+ /**
2547
+ * Read an `npc new` spec from `--spec` or `--specfile`.
2548
+ *
2549
+ * @param {{spec?: string, specfile?: string}} argv
2550
+ * @returns {{spec: unknown, file?: string, text?: string}} The spec as parsed,
2551
+ * and the file and text it was read from where it came from a file.
2552
+ */
2553
+ function readNpcSpec(argv) {
2554
+ if (argv.spec !== undefined) {
2555
+ try {
2556
+ return { spec: JSON.parse(argv.spec) };
2557
+ } catch (err) {
2558
+ throw new Error(`\`--spec\` is not JSON: ${err.message}`);
2559
+ }
2560
+ }
2561
+ const file = argv.specfile;
2562
+ const ext = path.extname(file).toLowerCase();
2563
+ if (![".json", ".yaml", ".yml"].includes(ext))
2564
+ throw new Error(`\`--specfile\` ${file} must be a .json, .yaml or .yml file`);
2565
+ let text;
2566
+ try {
2567
+ text = fs.readFileSync(file, "utf8");
2568
+ } catch (err) {
2569
+ throw new Error(`\`--specfile\` ${file} cannot be read: ${err.message}`);
2570
+ }
2571
+ try {
2572
+ return { spec: ext === ".json" ? JSON.parse(text) : YAML.parse(text), file, text };
2573
+ } catch (err) {
2574
+ throw Object.assign(new Error(`${file}: error: ${err.message.split("\n")[0]}`), {
2575
+ located: true,
2576
+ });
2577
+ }
2578
+ }
2579
+
2580
+ /**
2581
+ * An error located at a note, or at one key of its frontmatter where the note
2582
+ * states it.
2583
+ *
2584
+ * @param {{file?: string, raw?: string}} note - Where the note is.
2585
+ * @param {Array<string|number>} keyPath - The frontmatter key at fault, or the
2586
+ * map a missing key belongs in.
2587
+ * @param {string} message - What is wrong.
2588
+ * @returns {Error} The error, for `reportFailure` to print as a diagnostic.
2589
+ */
2590
+ function noteError(note, keyPath, message) {
2591
+ if (!note.file) return new Error(message);
2592
+ return Object.assign(new Error(message), {
2593
+ file: note.file,
2594
+ keyPath,
2595
+ position: positionOfFrontmatterPath(note.raw ?? "", keyPath, { key: true }),
2596
+ });
2597
+ }
2598
+
2599
+ /**
2600
+ * `npc new`: resolve the spec's culture through the content tree, then the
2601
+ * culture's appearance profile, its language and the language's name lists,
2602
+ * and write the note. A missing link at any step is an error naming the note
2603
+ * to fix.
2604
+ *
2605
+ * @param {object} config - The resolved pack configuration.
2606
+ * @param {{spec?: string, specfile?: string, seed?: string, out?: string}} argv
2607
+ */
2608
+ function newNpc(config, argv) {
2609
+ const { spec, file: specFile, text: specText } = readNpcSpec(argv);
2610
+ const problems = npcSpecProblems(spec);
2611
+ if (problems.length) throw new Error(`npc new: ${problems.join("; ")}`);
2612
+ if (argv.out && fs.existsSync(argv.out))
2613
+ throw new Error(`${argv.out} already exists, and \`npc new\` never overwrites a note`);
2614
+
2615
+ const root = config.paths.content;
2616
+ const records = indexRecordsFor({
2617
+ contentBase: root,
2618
+ config,
2619
+ skipDirectories: config.skipDirectories,
2620
+ problems: [],
2621
+ });
2622
+ const index = buildLinkIndex(root, {
2623
+ config,
2624
+ records,
2625
+ skipDirectories: config.skipDirectories,
2626
+ });
2627
+ const located = (hit) => ({
2628
+ file: hit.file ? path.relative(process.cwd(), hit.file) : undefined,
2629
+ raw: hit.raw,
2630
+ });
2631
+
2632
+ const written = spec.data.culture;
2633
+ const key = cultureKey(written, index);
2634
+ const hit = key ? index.addressHit(key) : undefined;
2635
+ if (!hit)
2636
+ throw new Error(
2637
+ `\`data.culture\` ${JSON.stringify(written)} names no note in this package or ` +
2638
+ "its dependencies",
2639
+ );
2640
+ const culture = hit.fm ?? hit;
2641
+ if (String(culture.type ?? hit.type) !== "lore" || culture.subType !== "culture")
2642
+ throw new Error(
2643
+ `\`data.culture\` ${JSON.stringify(written)} is not a culture: it names a ` +
2644
+ `${culture.type ?? hit.type ?? "note"} of subType ${culture.subType ?? "none"}`,
2645
+ );
2646
+ const cultureNote = located(hit);
2647
+ const profile = culture.data?.appearanceProfile;
2648
+ if (profile === undefined || profile === null)
2649
+ throw noteError(
2650
+ cultureNote,
2651
+ ["data"],
2652
+ `${key} states no \`data.appearanceProfile\`, so \`npc new\` cannot draw its ` +
2653
+ "people's look",
2654
+ );
2655
+ const profileErrors = profileFindings(
2656
+ { file: cultureNote.file, raw: hit.raw, fm: culture },
2657
+ BEING_LOOK_VOCABULARY,
2658
+ );
2659
+ if (profileErrors.length) {
2660
+ for (const finding of profileErrors) emitDiagnostic(finding);
2661
+ throw new Error(`${key}'s appearance profile has ${profileErrors.length} problem(s)`);
2662
+ }
2663
+
2664
+ const tongue = culture.data?.language;
2665
+ if (tongue === undefined || tongue === null)
2666
+ throw noteError(
2667
+ cultureNote,
2668
+ ["data"],
2669
+ `${key} states no \`data.language\`, so \`npc new\` has no name lists to draw ` +
2670
+ "from; name the language skill note its people speak",
2671
+ );
2672
+ const tuple = parseAddress(tongue, {
2673
+ package: index.contentPackage,
2674
+ system: "note",
2675
+ type: "skill",
2676
+ types: index.types,
2677
+ packages: index.packages,
2678
+ });
2679
+ const languageKey = tuple.reason ? undefined : renderAddress(tuple);
2680
+ const languageHit = languageKey ? index.addressHit(languageKey) : undefined;
2681
+ if (!languageHit)
2682
+ throw noteError(
2683
+ cultureNote,
2684
+ ["data", "language"],
2685
+ `${key}'s \`data.language\` names no note in this package or its dependencies`,
2686
+ );
2687
+ const language = languageHit.fm ?? languageHit;
2688
+ if (String(language.type ?? languageHit.type) !== "skill" || language.subType !== "language")
2689
+ throw noteError(
2690
+ cultureNote,
2691
+ ["data", "language"],
2692
+ `${key}'s \`data.language\` ${languageKey} is not a language: it names a ` +
2693
+ `${language.type ?? languageHit.type ?? "note"} of subType ` +
2694
+ `${language.subType ?? "none"}`,
2695
+ );
2696
+ const languageNote = located(languageHit);
2697
+ const names = language.data?.names;
2698
+ if (names === undefined || names === null)
2699
+ throw noteError(
2700
+ languageNote,
2701
+ ["data"],
2702
+ `${languageKey} states no \`data.names\`, so \`npc new\` has no name lists to ` +
2703
+ `draw ${key}'s names from`,
2704
+ );
2705
+ const namesErrors = namesFindings(
2706
+ { file: languageNote.file, raw: languageHit.raw, fm: language },
2707
+ BEING_LOOK_VOCABULARY,
2708
+ );
2709
+ if (namesErrors.length) {
2710
+ for (const finding of namesErrors) emitDiagnostic(finding);
2711
+ throw new Error(`${languageKey}'s name lists have ${namesErrors.length} problem(s)`);
2712
+ }
2713
+
2714
+ const { taken, ...defaults } = npcDefaults(index, key);
2715
+ if (typeof spec.shortcode === "string" && taken.has(spec.shortcode))
2716
+ throw new Error(`\`shortcode: ${spec.shortcode}\` already names a being in this package`);
2717
+ let text;
2718
+ try {
2719
+ ({ text } = generateNpc(spec, {
2720
+ profile,
2721
+ names,
2722
+ language: languageKey,
2723
+ seed: argv.seed,
2724
+ present: worldInvariants(index).present,
2725
+ dateContext: reckoningContext(index),
2726
+ defaults,
2727
+ taken,
2728
+ }));
2729
+ } catch (err) {
2730
+ // A name that does not fit is the spec's fault, so a spec file
2731
+ // locates it at the name it states.
2732
+ if (specFile && Array.isArray(err?.keyPath))
2733
+ Object.assign(err, {
2734
+ file: specFile,
2735
+ position: positionOfYamlPath(specText ?? "", err.keyPath),
2736
+ });
2737
+ throw err;
2738
+ }
2739
+ if (!argv.out) {
2740
+ process.stdout.write(text);
2741
+ return;
2742
+ }
2743
+ fs.mkdirSync(path.dirname(argv.out), { recursive: true });
2744
+ try {
2745
+ fs.writeFileSync(argv.out, text, { flag: "wx" });
2746
+ } catch (err) {
2747
+ if (err?.code === "EEXIST")
2748
+ throw new Error(`${argv.out} already exists, and \`npc new\` never overwrites a note`);
2749
+ throw err;
2750
+ }
2751
+ }
2752
+
2753
+ /**
2754
+ * `package-build npc new` — a complete NPC being note from a spec, its
2755
+ * appearance drawn from its culture's profile.
2756
+ *
2757
+ * @returns {object} The yargs command module.
2758
+ */
2759
+ function npcCommand() {
2760
+ return {
2761
+ command: "npc <action>",
2762
+ describe:
2763
+ "Write a new NPC being note from a spec, its culture's appearance profile and " +
2764
+ "its language's name lists",
2765
+ builder: (yargs) => {
2766
+ yargs.positional("action", {
2767
+ describe: "The action to perform.",
2768
+ type: "string",
2769
+ choices: ["new"],
2770
+ });
2771
+ yargs.option("spec", {
2772
+ describe:
2773
+ "The spec as a JSON string: frontmatter fields, with `data.culture` and " +
2774
+ "`data.gender` required.",
2775
+ type: "string",
2776
+ });
2777
+ yargs.option("specfile", {
2778
+ describe: "The spec as a .json, .yaml or .yml file.",
2779
+ type: "string",
2780
+ });
2781
+ yargs.option("seed", {
2782
+ describe:
2783
+ "Text that seeds generation beside the spec, to draw a different person " +
2784
+ "from the same spec.",
2785
+ type: "string",
2786
+ });
2787
+ yargs.option("out", {
2788
+ describe:
2789
+ "Write the note to this new file instead of standard output. An " +
2790
+ "existing file is never overwritten.",
2791
+ type: "string",
2792
+ });
2793
+ yargs.check((parsed) => {
2794
+ if ((parsed.spec === undefined) === (parsed.specfile === undefined))
2795
+ throw new Error("`npc new` takes exactly one of `--spec` and `--specfile`");
2796
+ return true;
2797
+ });
2798
+ },
2799
+ handler: async (argv) => {
2800
+ try {
2801
+ newNpc(loadPackConfig(), argv);
2802
+ } catch (err) {
2803
+ reportFailure(err);
2804
+ process.exitCode = 1;
2805
+ }
2806
+ },
2807
+ };
2808
+ }
@@ -18,6 +18,7 @@ import { formatGenerated } from "../engine/format-generated.mjs";
18
18
  import { loadContentFormat } from "../engine/content-format.mjs";
19
19
  import {
20
20
  BEING_ARCHETYPES,
21
+ BEING_LOOK_VOCABULARY,
21
22
  NOTE_TOP_LEVEL_FIELDS,
22
23
  NOTE_VOCABULARY,
23
24
  SHARED_DATA_FIELDS,
@@ -236,20 +237,42 @@ const details = fs.readFileSync(detailsPath, "utf8");
236
237
  if (!/<!-- archetypes:start -->[\s\S]*?<!-- archetypes:end -->/.test(details)) {
237
238
  throw new Error("The archetype reference requires its generation markers");
238
239
  }
240
+ if (!/<!-- being-look:start -->[\s\S]*?<!-- being-look:end -->/.test(details)) {
241
+ throw new Error("The being-look reference requires its generation markers");
242
+ }
239
243
  const archetypes = Object.entries(BEING_ARCHETYPES)
240
244
  .map(([name, meaning]) => `- ${name}: ${meaning}`)
241
245
  .join("\n");
246
+ // One table per field of a being's look, every word with its label and meaning,
247
+ // written from the declaration the lint closes each field to.
248
+ const beingLook = Object.entries(BEING_LOOK_VOCABULARY)
249
+ .flatMap(([field, words]) => [
250
+ `**\`data.${field}\`**`,
251
+ "",
252
+ "| Word | Label | Meaning |",
253
+ "| --- | --- | --- |",
254
+ ...words.map(
255
+ ({ value, label, describe }) => `| \`${value}\` | ${cell(label)} | ${cell(describe)} |`,
256
+ ),
257
+ "",
258
+ ])
259
+ .join("\n");
242
260
  const updatedDetails = await formatGenerated(
243
- details.replace(
244
- /<!-- archetypes:start -->[\s\S]*?<!-- archetypes:end -->/,
245
- `<!-- archetypes:start -->\n${archetypes}\n<!-- archetypes:end -->`,
246
- ),
261
+ details
262
+ .replace(
263
+ /<!-- archetypes:start -->[\s\S]*?<!-- archetypes:end -->/,
264
+ `<!-- archetypes:start -->\n${archetypes}\n<!-- archetypes:end -->`,
265
+ )
266
+ .replace(
267
+ /<!-- being-look:start -->[\s\S]*?<!-- being-look:end -->/,
268
+ `<!-- being-look:start -->\n\n${beingLook}\n<!-- being-look:end -->`,
269
+ ),
247
270
  detailsPath,
248
271
  );
249
272
  if (process.argv.includes("--check")) {
250
273
  if (details !== updatedDetails) {
251
274
  console.error(
252
- "docs/reference/format-details.md: error: run node ci/content-format-reference.mjs to refresh the archetype reference",
275
+ "docs/reference/format-details.md: error: run node ci/content-format-reference.mjs to refresh the archetype and being-look references",
253
276
  );
254
277
  process.exitCode = 1;
255
278
  }
package/docs/api.md CHANGED
@@ -323,29 +323,30 @@ The note types the engine itself declares — vocabulary that is a fact about th
323
323
 
324
324
  An addressed content note accepts exactly these top-level keys, in this order: `shortcode`, `name`, `type`, `subType`, `description`, `tags`, `data`, `dnd5e`, `hm3`, `sohl`. A key outside this list is an error at its own line. The system blocks are one per system the address vocabulary recognizes, sorted by name after the fixed keys, so a further system is a change to `SYSTEM_IDS` alone. No dnd5e-specific document types are declared. Within that shape, the closed vocabularies are the `data:` container and each type's `subType`.
325
325
 
326
- | Export | Signature | Returns | Use it when |
327
- | ------------------------- | -------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------- |
328
- | `DRAFT_TAG` | `const DRAFT_TAG` | — | naming the declared tag that marks a note as unfinished |
329
- | `GM_TAG` | `const GM_TAG` | — | naming the declared tag that marks a GM-only note |
330
- | `BEING_ARCHETYPES` | `const BEING_ARCHETYPES` | `Readonly<Record<string, string>>` | looking up the case-sensitive being archetypes and their meanings |
331
- | `NOTE_VOCABULARY` | `const NOTE_VOCABULARY` | — | looking up, per content type, its `data:` field list and its closed `subType` values |
332
- | `NOTE_TOP_LEVEL_FIELDS` | `const NOTE_TOP_LEVEL_FIELDS` | `readonly TopLevelFieldSpec[]` | reading the top-level keys a note accepts, in formatted order, with what each one means |
333
- | `NOTE_TOP_LEVEL_KEYS` | `const NOTE_TOP_LEVEL_KEYS` | `readonly string[]` | reading just the key names, in the order the formatter writes them |
334
- | `NOTE_TOP_LEVEL_KEY_SET` | `const NOTE_TOP_LEVEL_KEY_SET` | `ReadonlySet<string>` | testing whether a top-level key is one the note format declares |
335
- | `SHARED_DATA_FIELDS` | `const SHARED_DATA_FIELDS` | `readonly DataFieldSpec[]` | reading the `data:` keys every type accepts, whatever it is — the art slots legal everywhere |
336
- | `EVENTS_FIELD` | `const EVENTS_FIELD` | `DataFieldSpec` | reading the one `events` declaration that `lore`, `place` and `affiliation` share, with its check |
337
- | `DECLARED_TAGS` | `const DECLARED_TAGS` | — | looking up the tags a note type may declare, grouped |
338
- | `declaredTags` | `declaredTags(type, groups)` | `readonly string[]` | reading the declared tags a note of a type may carry, flattened |
339
- | `applicableTagGroups` | `applicableTagGroups(type, groups)` | `object[]` | reading the declared tag groups that apply to a note type |
340
- | `exclusiveTagGroups` | `exclusiveTagGroups(type, groups)` | `Array<{slot, tags}>` | reading single-valued tag slots declared for a note type |
341
- | `hasTag` | `hasTag(fm, tag)` | `boolean` | checking whether a note carries a given tag, whatever scalar-or-list form it was authored in |
342
- | `isDraftNote` | `isDraftNote(fm)` | `boolean` | checking whether a note is tagged as an unfinished draft |
343
- | `isGmNote` | `isGmNote(fm)` | `boolean` | checking whether a note is restricted to GM-facing output |
344
- | `subTypeCharsetMessage` | `subTypeCharsetMessage(value)` | `string` | building the message for a `subType` outside the address charset |
345
- | `typeCharsetMessage` | `typeCharsetMessage(type)` | `string` | building the message for a `type` outside the address charset |
346
- | `assertVocabularyCharset` | `assertVocabularyCharset(vocabulary, where)` | throws | refusing a vocabulary declaration whose type or subType breaks the address charset |
347
- | `dataFields` | `dataFields(type, vocabulary)` | `readonly DataFieldSpec[] \| undefined` | looking up the `data:` keys a note type may carry |
348
- | `subTypes` | `subTypes(type, vocabulary)` | `readonly string[] \| null \| undefined` | looking up the closed `subType` values a note type declares |
326
+ | Export | Signature | Returns | Use it when |
327
+ | ------------------------- | -------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
328
+ | `DRAFT_TAG` | `const DRAFT_TAG` | — | naming the declared tag that marks a note as unfinished |
329
+ | `GM_TAG` | `const GM_TAG` | — | naming the declared tag that marks a GM-only note |
330
+ | `BEING_ARCHETYPES` | `const BEING_ARCHETYPES` | `Readonly<Record<string, string>>` | looking up the case-sensitive being archetypes and their meanings |
331
+ | `BEING_LOOK_VOCABULARY` | `const BEING_LOOK_VOCABULARY` | `Readonly<Record<string, readonly LookWord[]>>` | reading the closed words a being's gender, frame and appearance take, each with its label and meaning, keyed by `data:` field |
332
+ | `NOTE_VOCABULARY` | `const NOTE_VOCABULARY` | — | looking up, per content type, its `data:` field list and its closed `subType` values |
333
+ | `NOTE_TOP_LEVEL_FIELDS` | `const NOTE_TOP_LEVEL_FIELDS` | `readonly TopLevelFieldSpec[]` | reading the top-level keys a note accepts, in formatted order, with what each one means |
334
+ | `NOTE_TOP_LEVEL_KEYS` | `const NOTE_TOP_LEVEL_KEYS` | `readonly string[]` | reading just the key names, in the order the formatter writes them |
335
+ | `NOTE_TOP_LEVEL_KEY_SET` | `const NOTE_TOP_LEVEL_KEY_SET` | `ReadonlySet<string>` | testing whether a top-level key is one the note format declares |
336
+ | `SHARED_DATA_FIELDS` | `const SHARED_DATA_FIELDS` | `readonly DataFieldSpec[]` | reading the `data:` keys every type accepts, whatever it is — the art slots legal everywhere |
337
+ | `EVENTS_FIELD` | `const EVENTS_FIELD` | `DataFieldSpec` | reading the one `events` declaration that `lore`, `place` and `affiliation` share, with its check |
338
+ | `DECLARED_TAGS` | `const DECLARED_TAGS` | — | looking up the tags a note type may declare, grouped |
339
+ | `declaredTags` | `declaredTags(type, groups)` | `readonly string[]` | reading the declared tags a note of a type may carry, flattened |
340
+ | `applicableTagGroups` | `applicableTagGroups(type, groups)` | `object[]` | reading the declared tag groups that apply to a note type |
341
+ | `exclusiveTagGroups` | `exclusiveTagGroups(type, groups)` | `Array<{slot, tags}>` | reading single-valued tag slots declared for a note type |
342
+ | `hasTag` | `hasTag(fm, tag)` | `boolean` | checking whether a note carries a given tag, whatever scalar-or-list form it was authored in |
343
+ | `isDraftNote` | `isDraftNote(fm)` | `boolean` | checking whether a note is tagged as an unfinished draft |
344
+ | `isGmNote` | `isGmNote(fm)` | `boolean` | checking whether a note is restricted to GM-facing output |
345
+ | `subTypeCharsetMessage` | `subTypeCharsetMessage(value)` | `string` | building the message for a `subType` outside the address charset |
346
+ | `typeCharsetMessage` | `typeCharsetMessage(type)` | `string` | building the message for a `type` outside the address charset |
347
+ | `assertVocabularyCharset` | `assertVocabularyCharset(vocabulary, where)` | throws | refusing a vocabulary declaration whose type or subType breaks the address charset |
348
+ | `dataFields` | `dataFields(type, vocabulary)` | `readonly DataFieldSpec[] \| undefined` | looking up the `data:` keys a note type may carry |
349
+ | `subTypes` | `subTypes(type, vocabulary)` | `readonly string[] \| null \| undefined` | looking up the closed `subType` values a note type declares |
349
350
 
350
351
  ### `engine.infobox`
351
352
 
@@ -507,6 +508,19 @@ What trade a settlement supports (`data.market`): the six-step scale, stated onc
507
508
  | `marketClass` | `marketClass(value)` | `object\|undefined` | the class a value names, for rendering what a number means |
508
509
  | `checkMarket` | `checkMarket(note)` | `object[]` | linting a place's `market` — a value off the scale, located at the value that states it |
509
510
 
511
+ ### `engine.settlementTables`
512
+
513
+ A region's settlements table — a markdown table under `## Settlements` on a `place` note, one row per settlement with its market, its people and its holder — held to its declared columns, to the holding model the region's tags name, and to the notes its rows link. `package-build lint` runs it as part of the `place` check; a note with no such table is not read.
514
+
515
+ | Export | Signature | Returns | Use it when |
516
+ | ------------------------ | ---------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
517
+ | `HOLDING_MODELS` | `const HOLDING_MODELS` | `readonly object[]` | the holding models in order, each its `tag`, the `holder` its Held by cell names, and whether a `person` may hold |
518
+ | `SETTLEMENT_COLUMNS` | `const SETTLEMENT_COLUMNS` | `readonly object[]` | the table's columns in header order, each its `name` and whether it is `required` |
519
+ | `SETTLEMENTS_HEADING` | `const SETTLEMENTS_HEADING` | `string` | the level-two heading text a settlements table stands under |
520
+ | `SETTLEMENT_TABLE_RULES` | `const SETTLEMENT_TABLE_RULES` | `readonly object[]` | the rules in order, each its `name` — the prefix its findings carry — and what it `describe`s |
521
+ | `findSettlementTables` | `findSettlementTables(raw)` | `{ heading: number, tables: object[] }` | reading a note's settlements tables, each with its header, its rows' cells and their 1-based lines and columns |
522
+ | `checkSettlementTables` | `checkSettlementTables(note, { index })` | `object[]` | linting a place note's settlements tables against the corpus the index holds |
523
+
510
524
  ### `engine.mapPlaces`
511
525
 
512
526
  The places a map is drawn from — this package's index records and every fetched dependency's entries, read through one shape — and what their `parents` say: the world, the continents, each place's continent, and everything the containment tree can get wrong, reported as findings located at the entry that states it.
@@ -35,6 +35,8 @@ The [note-type reference](../reference/note-types.md) lists every field accepted
35
35
 
36
36
  A being's `data.archetypes` names the roles it can fill in an adventure. Choose every specific role that fits, such as `entertainer` for a performer or `guildsperson` for someone whose professional training or connections matter. Use `[commoner]` when no specific archetype fits. `commoner` cannot share the list with another value. The [archetype reference](../reference/format-details.md#actors) defines the available roles.
37
37
 
38
+ A being's look is stated in a closed vocabulary. `data.gender` and `data.frame` take one word each, as do `data.appearance.eye_color`, `hair_color` and `skin_color`; `data.appearance.complexion` takes one word or a list of them. A value outside a field's list — a variant spelling such as `gray`, a word from another field's list, or an empty string — is an error naming the words the field takes. Leave a field out when the fact is unrecorded. `data.appearance.extra_features` is a free-text list for the marks no word covers. The [look reference](../reference/format-details.md#a-beings-look) gives every word and what it means.
39
+
38
40
  A `sohl:`, `hm3:` or `dnd5e:` block holds values for that Foundry system. Its `system:` child contains values sent to the system's DataModel. The system block can override shared routing and art where the declared field permits it. `data.pack` and `data.packFolder` are shared routes; a system-specific route belongs in that system block. The [detailed mapping reference](../reference/format-details.md#mappings-every-type-shares) and [system-block explanation](../reference/format-details.md#frontmatter-has-three-regions) cover precedence and emitted fields.
39
41
 
40
42
  One body describes the subject for both systems. A being's `{#appearance}` and `{#dossier}` sections feed SoHL Actor `system.appearance` and `system.dossier`, and HM3 Actor `system.description` and `system.biography`. An Item's `{#description}` page becomes a shared JournalEntryPage. Its resolved `@UUID[...]` pointer is stored in SoHL Item `system.docHtml` and HM3 Item `system.description`. An Item with no `{#description}` anchor uses its first page. Other H1 pages remain linkable.