@heroiclands/package-build 23.3.0 → 23.3.2

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,23 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Cultures can inherit their language and appearance from parent cultures when generating characters.
8
+
9
+ ## 23.3.1
10
+
11
+ ### Patch Changes
12
+
13
+ **New npcs**
14
+
15
+ - `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.
16
+ - 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.
17
+ - 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`.
18
+ - 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.
19
+ - `package-build lint` checks a profile's words, weights and ranges, and a language's pattern and lists.
20
+
3
21
  ## 23.3.0
4
22
 
5
23
  ### Minor 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,15 @@ 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";
144
+ import { cultureFieldSource, cultureHierarchyProblems } from "../engine/culture-parents.mjs";
130
145
 
131
146
  /**
132
147
  * The packs `unpack` extracts.
@@ -241,7 +256,8 @@ export function registerContentCommands(cli) {
241
256
  )
242
257
  .command(withIndexPreflight(mapCommand()))
243
258
  .command(withIndexPreflight(reachabilityCommand()))
244
- .command(addressesCommand());
259
+ .command(addressesCommand())
260
+ .command(withIndexPreflight(npcCommand()));
245
261
  }
246
262
 
247
263
  /** Optional sentence findings and note-level readability reports. */
@@ -490,6 +506,12 @@ function docsCommand() {
490
506
  type: "string",
491
507
  default: "docs",
492
508
  });
509
+ yargs.option("seed", {
510
+ describe:
511
+ "Text that seeds generation beside the spec, to draw a different person " +
512
+ "from the same spec.",
513
+ type: "string",
514
+ });
493
515
  yargs.option("out", {
494
516
  describe: "Write to this file instead of the configured location.",
495
517
  type: "string",
@@ -1555,6 +1577,12 @@ function contentIndexCommand() {
1555
1577
  describe: "Content tree to read. Defaults to the configured contentBase.",
1556
1578
  type: "string",
1557
1579
  });
1580
+ yargs.option("seed", {
1581
+ describe:
1582
+ "Text that seeds generation beside the spec, to draw a different person " +
1583
+ "from the same spec.",
1584
+ type: "string",
1585
+ });
1558
1586
  yargs.option("out", {
1559
1587
  describe:
1560
1588
  "Directory to write into. Defaults to the configured " +
@@ -2515,3 +2543,281 @@ function packageCommand() {
2515
2543
  },
2516
2544
  };
2517
2545
  }
2546
+
2547
+ /**
2548
+ * Read an `npc new` spec from `--spec` or `--specfile`.
2549
+ *
2550
+ * @param {{spec?: string, specfile?: string}} argv
2551
+ * @returns {{spec: unknown, file?: string, text?: string}} The spec as parsed,
2552
+ * and the file and text it was read from where it came from a file.
2553
+ */
2554
+ function readNpcSpec(argv) {
2555
+ if (argv.spec !== undefined) {
2556
+ try {
2557
+ return { spec: JSON.parse(argv.spec) };
2558
+ } catch (err) {
2559
+ throw new Error(`\`--spec\` is not JSON: ${err.message}`);
2560
+ }
2561
+ }
2562
+ const file = argv.specfile;
2563
+ const ext = path.extname(file).toLowerCase();
2564
+ if (![".json", ".yaml", ".yml"].includes(ext))
2565
+ throw new Error(`\`--specfile\` ${file} must be a .json, .yaml or .yml file`);
2566
+ let text;
2567
+ try {
2568
+ text = fs.readFileSync(file, "utf8");
2569
+ } catch (err) {
2570
+ throw new Error(`\`--specfile\` ${file} cannot be read: ${err.message}`);
2571
+ }
2572
+ try {
2573
+ return { spec: ext === ".json" ? JSON.parse(text) : YAML.parse(text), file, text };
2574
+ } catch (err) {
2575
+ throw Object.assign(new Error(`${file}: error: ${err.message.split("\n")[0]}`), {
2576
+ located: true,
2577
+ });
2578
+ }
2579
+ }
2580
+
2581
+ /**
2582
+ * An error located at a note, or at one key of its frontmatter where the note
2583
+ * states it.
2584
+ *
2585
+ * @param {{file?: string, raw?: string}} note - Where the note is.
2586
+ * @param {Array<string|number>} keyPath - The frontmatter key at fault, or the
2587
+ * map a missing key belongs in.
2588
+ * @param {string} message - What is wrong.
2589
+ * @returns {Error} The error, for `reportFailure` to print as a diagnostic.
2590
+ */
2591
+ function noteError(note, keyPath, message) {
2592
+ if (!note.file) return new Error(message);
2593
+ return Object.assign(new Error(message), {
2594
+ file: note.file,
2595
+ keyPath,
2596
+ position: positionOfFrontmatterPath(note.raw ?? "", keyPath, { key: true }),
2597
+ });
2598
+ }
2599
+
2600
+ /**
2601
+ * `npc new`: resolve the spec's culture through the content tree, then the
2602
+ * culture's appearance profile, its language and the language's name lists,
2603
+ * and write the note. A missing link at any step is an error naming the note
2604
+ * to fix.
2605
+ *
2606
+ * @param {object} config - The resolved pack configuration.
2607
+ * @param {{spec?: string, specfile?: string, seed?: string, out?: string}} argv
2608
+ */
2609
+ function newNpc(config, argv) {
2610
+ const { spec, file: specFile, text: specText } = readNpcSpec(argv);
2611
+ const problems = npcSpecProblems(spec);
2612
+ if (problems.length) throw new Error(`npc new: ${problems.join("; ")}`);
2613
+ if (argv.out && fs.existsSync(argv.out))
2614
+ throw new Error(`${argv.out} already exists, and \`npc new\` never overwrites a note`);
2615
+
2616
+ const root = config.paths.content;
2617
+ const corpusProblems = [];
2618
+ const records = indexRecordsFor({
2619
+ contentBase: root,
2620
+ config,
2621
+ skipDirectories: config.skipDirectories,
2622
+ problems: corpusProblems,
2623
+ });
2624
+ const index = buildLinkIndex(root, {
2625
+ config,
2626
+ records,
2627
+ skipDirectories: config.skipDirectories,
2628
+ });
2629
+ const located = (hit) => ({
2630
+ file: hit.file ? path.relative(process.cwd(), hit.file) : undefined,
2631
+ raw: hit.raw,
2632
+ });
2633
+
2634
+ const written = spec.data.culture;
2635
+ const key = cultureKey(written, index);
2636
+ const hit = key ? index.addressHit(key) : undefined;
2637
+ if (!hit)
2638
+ throw new Error(
2639
+ `\`data.culture\` ${JSON.stringify(written)} names no note in this package or ` +
2640
+ "its dependencies",
2641
+ );
2642
+ const culture = hit.fm ?? hit;
2643
+ if (String(culture.type ?? hit.type) !== "lore" || culture.subType !== "culture")
2644
+ throw new Error(
2645
+ `\`data.culture\` ${JSON.stringify(written)} is not a culture: it names a ` +
2646
+ `${culture.type ?? hit.type ?? "note"} of subType ${culture.subType ?? "none"}`,
2647
+ );
2648
+ const hierarchyProblems = cultureHierarchyProblems(records, key);
2649
+ for (const problem of hierarchyProblems) emitDiagnostic(problem);
2650
+ if (hierarchyProblems.some((problem) => problem.severity === "error")) {
2651
+ process.exitCode = 1;
2652
+ return;
2653
+ }
2654
+ const cultureNote = located(hit);
2655
+ const profile = culture.data?.appearanceProfile;
2656
+ if (profile === undefined || profile === null)
2657
+ throw noteError(
2658
+ cultureNote,
2659
+ ["data"],
2660
+ `${key} states no \`data.appearanceProfile\`, so \`npc new\` cannot draw its ` +
2661
+ "people's look",
2662
+ );
2663
+ const profileSource = cultureFieldSource(records, key, "appearanceProfile") ?? hit;
2664
+ const profileErrors = profileFindings(
2665
+ {
2666
+ file: located(profileSource).file,
2667
+ raw: profileSource.raw,
2668
+ fm: profileSource.fm ?? profileSource,
2669
+ },
2670
+ BEING_LOOK_VOCABULARY,
2671
+ );
2672
+ if (profileErrors.length) {
2673
+ for (const finding of profileErrors) emitDiagnostic(finding);
2674
+ throw new Error(`${key}'s appearance profile has ${profileErrors.length} problem(s)`);
2675
+ }
2676
+
2677
+ const tongue = culture.data?.language;
2678
+ if (tongue === undefined || tongue === null)
2679
+ throw noteError(
2680
+ cultureNote,
2681
+ ["data"],
2682
+ `${key} states no \`data.language\`, so \`npc new\` has no name lists to draw ` +
2683
+ "from; name the language skill note its people speak",
2684
+ );
2685
+ const languageSource = cultureFieldSource(records, key, "language") ?? hit;
2686
+ const languageSourceNote = located(languageSource);
2687
+ const tuple = parseAddress(tongue, {
2688
+ package: index.contentPackage,
2689
+ system: "note",
2690
+ type: "skill",
2691
+ types: index.types,
2692
+ packages: index.packages,
2693
+ });
2694
+ const languageKey = tuple.reason ? undefined : renderAddress(tuple);
2695
+ const languageHit = languageKey ? index.addressHit(languageKey) : undefined;
2696
+ if (!languageHit)
2697
+ throw noteError(
2698
+ languageSourceNote,
2699
+ ["data", "language"],
2700
+ `${key}'s \`data.language\` names no note in this package or its dependencies`,
2701
+ );
2702
+ const language = languageHit.fm ?? languageHit;
2703
+ if (String(language.type ?? languageHit.type) !== "skill" || language.subType !== "language")
2704
+ throw noteError(
2705
+ languageSourceNote,
2706
+ ["data", "language"],
2707
+ `${key}'s \`data.language\` ${languageKey} is not a language: it names a ` +
2708
+ `${language.type ?? languageHit.type ?? "note"} of subType ` +
2709
+ `${language.subType ?? "none"}`,
2710
+ );
2711
+ const languageNote = located(languageHit);
2712
+ const names = language.data?.names;
2713
+ if (names === undefined || names === null)
2714
+ throw noteError(
2715
+ languageNote,
2716
+ ["data"],
2717
+ `${languageKey} states no \`data.names\`, so \`npc new\` has no name lists to ` +
2718
+ `draw ${key}'s names from`,
2719
+ );
2720
+ const namesErrors = namesFindings(
2721
+ { file: languageNote.file, raw: languageHit.raw, fm: language },
2722
+ BEING_LOOK_VOCABULARY,
2723
+ );
2724
+ if (namesErrors.length) {
2725
+ for (const finding of namesErrors) emitDiagnostic(finding);
2726
+ throw new Error(`${languageKey}'s name lists have ${namesErrors.length} problem(s)`);
2727
+ }
2728
+
2729
+ const { taken, ...defaults } = npcDefaults(index, key);
2730
+ if (typeof spec.shortcode === "string" && taken.has(spec.shortcode))
2731
+ throw new Error(`\`shortcode: ${spec.shortcode}\` already names a being in this package`);
2732
+ let text;
2733
+ try {
2734
+ ({ text } = generateNpc(spec, {
2735
+ profile,
2736
+ names,
2737
+ language: languageKey,
2738
+ seed: argv.seed,
2739
+ present: worldInvariants(index).present,
2740
+ dateContext: reckoningContext(index),
2741
+ defaults,
2742
+ taken,
2743
+ }));
2744
+ } catch (err) {
2745
+ // A name that does not fit is the spec's fault, so a spec file
2746
+ // locates it at the name it states.
2747
+ if (specFile && Array.isArray(err?.keyPath))
2748
+ Object.assign(err, {
2749
+ file: specFile,
2750
+ position: positionOfYamlPath(specText ?? "", err.keyPath),
2751
+ });
2752
+ throw err;
2753
+ }
2754
+ if (!argv.out) {
2755
+ process.stdout.write(text);
2756
+ return;
2757
+ }
2758
+ fs.mkdirSync(path.dirname(argv.out), { recursive: true });
2759
+ try {
2760
+ fs.writeFileSync(argv.out, text, { flag: "wx" });
2761
+ } catch (err) {
2762
+ if (err?.code === "EEXIST")
2763
+ throw new Error(`${argv.out} already exists, and \`npc new\` never overwrites a note`);
2764
+ throw err;
2765
+ }
2766
+ }
2767
+
2768
+ /**
2769
+ * `package-build npc new` — a complete NPC being note from a spec, its
2770
+ * appearance drawn from its culture's profile.
2771
+ *
2772
+ * @returns {object} The yargs command module.
2773
+ */
2774
+ function npcCommand() {
2775
+ return {
2776
+ command: "npc <action>",
2777
+ describe:
2778
+ "Write a new NPC being note from a spec, its culture's appearance profile and " +
2779
+ "its language's name lists",
2780
+ builder: (yargs) => {
2781
+ yargs.positional("action", {
2782
+ describe: "The action to perform.",
2783
+ type: "string",
2784
+ choices: ["new"],
2785
+ });
2786
+ yargs.option("spec", {
2787
+ describe:
2788
+ "The spec as a JSON string: frontmatter fields, with `data.culture` and " +
2789
+ "`data.gender` required.",
2790
+ type: "string",
2791
+ });
2792
+ yargs.option("specfile", {
2793
+ describe: "The spec as a .json, .yaml or .yml file.",
2794
+ type: "string",
2795
+ });
2796
+ yargs.option("seed", {
2797
+ describe:
2798
+ "Text that seeds generation beside the spec, to draw a different person " +
2799
+ "from the same spec.",
2800
+ type: "string",
2801
+ });
2802
+ yargs.option("out", {
2803
+ describe:
2804
+ "Write the note to this new file instead of standard output. An " +
2805
+ "existing file is never overwritten.",
2806
+ type: "string",
2807
+ });
2808
+ yargs.check((parsed) => {
2809
+ if ((parsed.spec === undefined) === (parsed.specfile === undefined))
2810
+ throw new Error("`npc new` takes exactly one of `--spec` and `--specfile`");
2811
+ return true;
2812
+ });
2813
+ },
2814
+ handler: async (argv) => {
2815
+ try {
2816
+ newNpc(loadPackConfig(), argv);
2817
+ } catch (err) {
2818
+ reportFailure(err);
2819
+ process.exitCode = 1;
2820
+ }
2821
+ },
2822
+ };
2823
+ }
package/docs/commands.md CHANGED
@@ -2766,3 +2766,194 @@ $ package-build addresses diff --from build/dist/module.zip
2766
2766
 
2767
2767
  `package-build package <action> [pack] [entry]`, `package-build deps fetch`,
2768
2768
  [Diagnostics](diagnostics.md).
2769
+
2770
+ ### `package-build npc new`
2771
+
2772
+ **NAME**
2773
+
2774
+ Write a new NPC being note from a spec, its look drawn from its culture's
2775
+ appearance profile and its name from its culture's language.
2776
+
2777
+ **SYNOPSIS**
2778
+
2779
+ ```
2780
+ package-build npc new (--spec <json> | --specfile <path>) [--seed <text>] [--out <path>]
2781
+ ```
2782
+
2783
+ **DESCRIPTION**
2784
+
2785
+ Writes one `type: being`, `subType: npc` note: the full `data` block, an
2786
+ `# Appearance {#appearance}` section and a `# Dossier {#dossier}` section, with
2787
+ no system block. The note goes to standard output, or to a new file with
2788
+ `--out`.
2789
+
2790
+ The spec is a possibly incomplete set of frontmatter fields, given as a JSON
2791
+ string with `--spec` or as a `.json`, `.yaml` or `.yml` file with
2792
+ `--specfile`; exactly one of the two is required. `data.culture` and
2793
+ `data.gender` are required in it. Every field the spec gives is written
2794
+ exactly as given, at every depth, and every field it leaves out is generated:
2795
+
2796
+ | Field | Generated value |
2797
+ | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
2798
+ | `name.full` | Drawn from the culture's language's name lists by one of its patterns for `data.gender`, the given name from the list for that gender. |
2799
+ | `shortcode` | The name transliterated to lowercase letters and digits and cut to twelve characters; a counter replaces its tail where a being holds it. |
2800
+ | `type`, `subType`, `tags` | `being`, `npc`, `[generated, draft]`. |
2801
+ | `data.icon`, `data.species`, `data.packFolder` | The commonest value among the package's npcs of the same culture, else among all its npcs; left out where none states one. |
2802
+ | `data.archetypes` | `[commoner]`, the archetype of a person who fits no more specific one. |
2803
+ | `data.templatePriority`, `data.occupation`, `data.died` | `null`. |
2804
+ | `data.lore`, `data.homes` | `[]`. |
2805
+ | `data.affiliations` | `{}`. |
2806
+ | `data.born` | An adult age of 18 to 60 years before the package's present, as a `<year>.<day>` date; `unknown` where the package states no present. |
2807
+ | `data.frame`, `data.appearance.*` | Weighted draws from the culture's appearance profile; `extra_features` is `[]`. |
2808
+ | `data.height` | From the profile's range for the gender, in feet and inches. |
2809
+ | `data.weight` | From the height and the frame's body-mass band, in pounds. A spec giving height and weight but no frame gets the frame they imply. |
2810
+
2811
+ Generation is seeded from the spec itself, through a canonical serialisation
2812
+ that sorts every map's keys: the same spec always writes the same note, whether
2813
+ or not it states a name, and the order the spec writes its fields in changes
2814
+ nothing. `--seed` adds text to that seed, so the same spec with a different
2815
+ `--seed` writes a different person; two people drawn from a spec that states
2816
+ only the culture and the gender differ only by their seeds.
2817
+
2818
+ `data.culture` resolves as any culture reference does, through this package's
2819
+ content tree and its dependencies, and must name a `lore` note of
2820
+ `subType: culture`. The culture supplies two things, and the command refuses a
2821
+ culture missing either, with an error at the culture note:
2822
+
2823
+ - **Its look**, from `data.appearanceProfile`: the words and ranges every
2824
+ appearance field is drawn from. See
2825
+ [appearance profiles](reference/format-details.md#appearance-profiles).
2826
+ - **Its names**, from the language skill note its `data.language` names, whose
2827
+ `data.names` holds the pattern and the lists. A language with no
2828
+ `data.names` is an error at the language note. See
2829
+ [name lists](reference/format-details.md#name-lists).
2830
+
2831
+ The content-index JSONL resolves a culture's `data.language` and
2832
+ `data.appearanceProfile` before this command reads them. A child culture may
2833
+ inherit each omitted field from its `data.parents`, while a stated value keeps
2834
+ precedence. The command consumes the resolved values and keeps the selected
2835
+ child culture on the generated note. A parent cycle or conflicting inherited
2836
+ sources are errors; complete fields on the selected culture do not excuse a
2837
+ cycle. See [culture parents](reference/format-details.md#culture-parents-and-inherited-fields).
2838
+
2839
+ A profile or name lists with problems are refused with a finding at each.
2840
+
2841
+ A spec that states `name.full` keeps it, and the name must fit the language's
2842
+ lists: one of the patterns for `data.gender`, with every placeholder filled by
2843
+ a name of its list, the given name from the list for `data.gender`. A name that does not fit is an
2844
+ error naming the part that fails and the language it was checked against; from
2845
+ a `--specfile`, the error is located at the name in the file. Where the
2846
+ gender has no given list of its own, the given name is drawn from, and
2847
+ checked against, every given list together.
2848
+
2849
+ Two top-level spec keys are section bodies rather than frontmatter.
2850
+ `appearance` is the Appearance section's markdown, written verbatim with its
2851
+ newlines and spacing; without it, the section is a paragraph written from the
2852
+ data. `dossier` is the Dossier section's markdown, likewise verbatim; without
2853
+ it, the section is its heading alone.
2854
+
2855
+ **OPTIONS**
2856
+
2857
+ | Positional | Type | Default | Description |
2858
+ | ---------- | -------------------- | ------- | ---------------------- |
2859
+ | `action` | string, one of `new` | — | The action to perform. |
2860
+
2861
+ | Option | Type | Default | Description |
2862
+ | ------------ | ------ | --------------- | -------------------------------------------------------------------------------- |
2863
+ | `--spec` | string | — | The spec as a JSON string. Exactly one of `--spec` and `--specfile` is required. |
2864
+ | `--specfile` | string | — | The spec as a `.json`, `.yaml` or `.yml` file. |
2865
+ | `--seed` | string | — | Text that seeds generation beside the spec, to draw a different person. |
2866
+ | `--out` | string | standard output | Write the note to this new file. An existing file is never overwritten. |
2867
+
2868
+ **EXIT STATUS**
2869
+
2870
+ 1 if neither or both of `--spec` and `--specfile` are given, if the spec
2871
+ cannot be read or lacks `data.culture` or `data.gender`, if `data.culture`
2872
+ names no culture note, if the culture states no appearance profile or no
2873
+ language, if its language states no name lists, if the profile or the lists
2874
+ have problems, if the spec's `name.full` does not fit the lists, if the spec's
2875
+ `shortcode` already names a being, or if `--out` names a file that exists.
2876
+ Otherwise 0.
2877
+
2878
+ **EXAMPLES**
2879
+
2880
+ A spec, `spec.yaml`, stating the culture, the gender, two archetypes, an
2881
+ occupation and the dossier, and no name:
2882
+
2883
+ ```yaml
2884
+ data:
2885
+ culture: khelathi
2886
+ gender: male
2887
+ archetypes: [warrior, mariner]
2888
+ occupation: River Fleet Officer
2889
+ dossier: |
2890
+ He holds a commission in the river fleet, and his service is a matter of
2891
+ record rather than of reputation.
2892
+ ```
2893
+
2894
+ Run against a package whose world states `present: "720.300"`, whose
2895
+ `khelathi` culture carries the profile in
2896
+ [appearance profiles](reference/format-details.md#appearance-profiles) and
2897
+ names `language: skill-khelathilng`, and whose language carries the lists in
2898
+ [name lists](reference/format-details.md#name-lists):
2899
+
2900
+ ```
2901
+ $ package-build npc new --specfile spec.yaml
2902
+ ---
2903
+ shortcode: khelemureziq
2904
+ name: {full: Khelemûr ez Iqu}
2905
+ type: being
2906
+ subType: npc
2907
+ tags: [generated, draft]
2908
+ data:
2909
+ templatePriority: null
2910
+ archetypes: [warrior, mariner]
2911
+ occupation: River Fleet Officer
2912
+ lore: []
2913
+ culture: khelathi
2914
+ homes: []
2915
+ affiliations: {}
2916
+ gender: male
2917
+ born: "699.134"
2918
+ died: null
2919
+ height: 5' 11"
2920
+ weight: 167 lbs
2921
+ frame: medium
2922
+ appearance:
2923
+ eye_color: brown
2924
+ hair_color: black
2925
+ skin_color: brown
2926
+ complexion: clear
2927
+ extra_features: []
2928
+ ---
2929
+
2930
+ # Appearance {#appearance}
2931
+
2932
+ Khelemûr ez Iqu is a 21-year-old man who stands 5'11" tall with a medium build. He has brown skin, clear complexion, black hair, and brown eyes.
2933
+
2934
+ # Dossier {#dossier}
2935
+
2936
+ He holds a commission in the river fleet, and his service is a matter of
2937
+ record rather than of reputation.
2938
+ ```
2939
+
2940
+ A spec file, `bad.yaml`, stating a name whose second part is in no list:
2941
+
2942
+ ```
2943
+ $ package-build npc new --specfile bad.yaml
2944
+ bad.yaml:2:9: error: name.full "Aguri ez Lut" does not fit the names of demo-note-skill-khelathilng: "Lut", the {place} part, is not in the place list
2945
+ ```
2946
+
2947
+ A spec inline, for a culture that names no language:
2948
+
2949
+ ```
2950
+ $ package-build npc new --spec '{"data":{"culture":"plainfolk","gender":"female"}}'
2951
+ assets/content/Cultures/Plainfolk.md:6:1: error: demo-note-lore-plainfolk states no `data.language`, so `npc new` has no name lists to draw from; name the language skill note its people speak
2952
+ ```
2953
+
2954
+ **SEE ALSO**
2955
+
2956
+ `package-build lint [root]`,
2957
+ [Appearance profiles](reference/format-details.md#appearance-profiles),
2958
+ [Name lists](reference/format-details.md#name-lists),
2959
+ [A being's look](reference/format-details.md#a-beings-look).
@@ -94,6 +94,15 @@ A region may list its settlements in a table under `## Settlements` — name, ma
94
94
 
95
95
  A being states its gender, frame and appearance in words from one closed vocabulary: `data.gender`, `data.frame`, and `data.appearance.eye_color`, `hair_color`, `skin_color` and `complexion` each take only the words listed for them, and any other value is an error at the value. `data.appearance.extra_features` stays free text. See [a being's look](reference/format-details.md#a-beings-look).
96
96
 
97
+ A culture note may state what its people look like in `data.appearanceProfile`: weighted words for frame, eyes, hair, skin and complexion, and height ranges by gender, all from the same vocabulary. A culture names its tongue in `data.language`, and that language skill note may state `data.names`: the patterns its speakers' names are built by and the lists each part is drawn from. `package-build npc new` writes a complete npc note from a spec of a culture and a gender, drawing the name from those lists and everything else the spec leaves out from the profile. See [appearance profiles](reference/format-details.md#appearance-profiles), [name lists](reference/format-details.md#name-lists) and [the command](commands.md#package-build-npc-new).
98
+
99
+ A culture may name its larger cultures in `data.parents`. Each entry names a
100
+ culture lore note. Omitted `data.language` and `data.appearanceProfile` fields
101
+ inherit independently through those parents; the content-index JSONL contains
102
+ the resolved values, which `npc new` reads directly. A stated field wins over
103
+ its inherited value. See [culture parents](reference/format-details.md#culture-parents-and-inherited-fields)
104
+ for a complete example, parent ordering and findings.
105
+
97
106
  Places, affiliations, lore and beings may name their culture in `data.culture`, and a `doc` of `subType: settingguide` must. A place or affiliation that names none inherits the culture of its parents. See [culture in the index](reference/format-details.md#culture-in-the-index).
98
107
 
99
108
  Blockquote alerts use `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, or `[!]`, with optional attributes after the marker. They replace the former info and warn div fences; secret blocks remain supported. See [alerts](authoring/links-and-markup.md#alerts).