@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.
@@ -13,13 +13,17 @@
13
13
 
14
14
  /**
15
15
  * A work a people tells, sings or writes: the `literature` lore subType and the
16
- * three optional facts only such a note may state about itself. Whose work it
17
- * is, `data.culture`, is a field every lore note but a culture note may state,
16
+ * optional facts only such a note may state about itself. Whose work it is,
17
+ * `data.culture`, is a field every lore note but a culture note may state,
18
18
  * declared in `culture-choice.mjs`.
19
19
  *
20
+ * `data.language` is shared with the `culture` subType: a work names the
21
+ * tongue it is composed in, and a culture names the tongue its people speak,
22
+ * whose name lists `package-build npc new` draws from.
23
+ *
20
24
  * `DataFieldSpec` declares the keys a type accepts, not the subType that may
21
- * write them, so {@link checkLiteratureNote} scopes these keys to `literature`
22
- * the way the calendar check scopes its own to `calendar`.
25
+ * write them, so {@link checkLiteratureNote} scopes these keys to the subTypes
26
+ * that mean them, the way the calendar check scopes its own to `calendar`.
23
27
  *
24
28
  * @module
25
29
  */
@@ -30,6 +34,9 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
30
34
  /** The `lore` subType whose notes are works of literature. */
31
35
  export const LITERATURE_SUBTYPE = "literature";
32
36
 
37
+ /** The `lore` subTypes that may name a language. */
38
+ export const LANGUAGE_SUBTYPES = Object.freeze([LITERATURE_SUBTYPE, "culture"]);
39
+
33
40
  /**
34
41
  * Require `data.language` to address a `skill` note whose subType is
35
42
  * `language`.
@@ -67,7 +74,27 @@ export function checkLanguageChoice(note, { index } = {}) {
67
74
  }
68
75
 
69
76
  /**
70
- * The fields only a `literature` note may state. Every one is optional.
77
+ * `data.language`: the tongue a work is composed in, or the tongue a culture's
78
+ * people speak. Optional on both.
79
+ *
80
+ * @type {object}
81
+ */
82
+ export const LANGUAGE_FIELD = Object.freeze({
83
+ name: "language",
84
+ shape: "an Address",
85
+ kind: "address",
86
+ ref: "skill",
87
+ accepts: Object.freeze(["skill"]),
88
+ check: checkLanguageChoice,
89
+ describe:
90
+ "The tongue the work is composed in, or on a culture note the tongue its people " +
91
+ "speak, as a language skill note. `package-build npc new` draws a culture's names " +
92
+ "from its language's `data.names`. A `literature` or `culture` note only.",
93
+ });
94
+
95
+ /**
96
+ * The fields only a `literature` note may state, beside the shared
97
+ * {@link LANGUAGE_FIELD}. Every one is optional.
71
98
  *
72
99
  * @type {ReadonlyArray<object>}
73
100
  */
@@ -90,39 +117,47 @@ export const LITERATURE_FIELDS = Object.freeze([
90
117
  "The beings, places, gods and other notes the work concerns, and each " +
91
118
  "event it concerns, named as `place-ironfells#sack`.",
92
119
  },
93
- {
94
- name: "language",
95
- shape: "an Address",
96
- kind: "address",
97
- ref: "skill",
98
- accepts: ["skill"],
99
- check: checkLanguageChoice,
100
- describe: "The tongue the work is composed in, as a language skill note.",
101
- },
102
120
  ]);
103
121
 
104
122
  const LITERATURE_KEYS = Object.freeze(LITERATURE_FIELDS.map((field) => field.name));
105
123
 
106
124
  /**
107
- * Refuse a literature field on a lore note of any other subType.
125
+ * Refuse a literature field on a lore note of any other subType, and
126
+ * `data.language` on a lore note that is neither a work nor a culture.
108
127
  *
109
128
  * @param {object} note
110
- * @returns {object[]} One error per literature key written off `literature`.
129
+ * @returns {object[]} One error per key written off the subTypes that take it.
111
130
  */
112
131
  export function checkLiteratureNote(note) {
113
132
  const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
114
133
  const subType = String(note?.fm?.subType ?? "").toLowerCase();
115
- if (type !== "lore" || subType === LITERATURE_SUBTYPE) return [];
134
+ if (type !== "lore") return [];
116
135
  const data = note?.fm?.data;
117
136
  if (!data || typeof data !== "object" || Array.isArray(data)) return [];
118
- return LITERATURE_KEYS.filter((key) => data[key] !== undefined && data[key] !== null).map(
119
- (key) => ({
120
- file: note.file,
121
- ...positionOfFrontmatterPath(note.raw ?? "", ["data", key], { key: true }),
122
- severity: "error",
123
- message:
124
- `\`data.${key}\` describes a work of literature, and this note's ` +
125
- `\`subType\` is not \`literature\``,
126
- }),
127
- );
137
+ const stated = (key) => data[key] !== undefined && data[key] !== null;
138
+ const finding = (key, message) => ({
139
+ file: note.file,
140
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", key], { key: true }),
141
+ severity: "error",
142
+ message,
143
+ });
144
+ const findings = [];
145
+ if (subType !== LITERATURE_SUBTYPE)
146
+ for (const key of LITERATURE_KEYS.filter(stated))
147
+ findings.push(
148
+ finding(
149
+ key,
150
+ `\`data.${key}\` describes a work of literature, and this note's ` +
151
+ `\`subType\` is not \`literature\``,
152
+ ),
153
+ );
154
+ if (!LANGUAGE_SUBTYPES.includes(subType) && stated(LANGUAGE_FIELD.name))
155
+ findings.push(
156
+ finding(
157
+ LANGUAGE_FIELD.name,
158
+ "`data.language` names the tongue of a work of literature or of a culture, " +
159
+ "and this note's `subType` is neither `literature` nor `culture`",
160
+ ),
161
+ );
162
+ return findings;
128
163
  }
@@ -484,6 +484,17 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
484
484
  // place or affiliation inherits from a parent here without
485
485
  // walking that parent's own chain.
486
486
  culture: record.data?.culture ?? undefined,
487
+ ...(parts.type === "lore" && record.subType === "culture" ?
488
+ {
489
+ data: {
490
+ language: record.data?.language,
491
+ appearanceProfile: record.data?.appearanceProfile,
492
+ },
493
+ }
494
+ : {}),
495
+ ...(parts.type === "skill" && record.subType === "language" ?
496
+ { data: { names: record.data?.names } }
497
+ : {}),
487
498
  population: record.data?.population ?? undefined,
488
499
  ...(Object.hasOwn(record.data ?? {}, "government") ?
489
500
  { government: record.data.government }
@@ -111,7 +111,14 @@ import { checkNoteEvents } from "./note-events.mjs";
111
111
  import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
112
112
  import { EVENT_ENTRY } from "./note-event-terms.mjs";
113
113
  import { CULTURE_FIELD, checkCultureSubType } from "./culture-choice.mjs";
114
- import { LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
114
+ import { checkCultureParents } from "./culture-parents.mjs";
115
+ import {
116
+ checkAppearanceProfile,
117
+ profileLookKeys,
118
+ requiredHeightGenders,
119
+ } from "./appearance-profile.mjs";
120
+ import { LANGUAGE_FIELD, LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
121
+ import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
115
122
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
116
123
  import { parseNoteDate } from "./note-dates.mjs";
117
124
  import { reckoningContext } from "./reckoning-markers.mjs";
@@ -429,6 +436,155 @@ function lookField(name, what) {
429
436
  };
430
437
  }
431
438
 
439
+ /**
440
+ * A culture's `data.appearanceProfile`: weighted words for each being-look
441
+ * field but `gender`, and height ranges keyed by gender. Its keys and the words
442
+ * each takes are read from {@link BEING_LOOK_VOCABULARY}; what a profile means
443
+ * and how `package-build npc new` draws from it is in `appearance-profile.mjs`.
444
+ *
445
+ * @type {DataFieldSpec}
446
+ */
447
+ const APPEARANCE_PROFILE_FIELD = Object.freeze({
448
+ name: "appearanceProfile",
449
+ kind: "map",
450
+ shape: "an appearance profile",
451
+ check: (note) => checkAppearanceProfile(note, BEING_LOOK_VOCABULARY),
452
+ describe:
453
+ "What this culture's people look like, for `package-build npc new`: " +
454
+ `${wordList(profileLookKeys(BEING_LOOK_VOCABULARY)).replace(/ or (?!.* or )/, " and ")} ` +
455
+ "each map words of that field's vocabulary to positive weights, and `height` maps a " +
456
+ "gender to a `{min, max}` range, " +
457
+ `${wordList([...requiredHeightGenders()]).replace(" or ", " and ")} required. A culture ` +
458
+ "note only. See [appearance profiles](format-details.md#appearance-profiles).",
459
+ fields: Object.freeze([
460
+ ...profileLookKeys(BEING_LOOK_VOCABULARY).map((key) =>
461
+ Object.freeze({
462
+ name: key,
463
+ kind: "map",
464
+ shape: "a map of words to weights",
465
+ required: true,
466
+ describe: `Weighted \`${key}\` words; a word left out is never drawn.`,
467
+ values: Object.freeze({
468
+ kind: "number",
469
+ shape: "a positive weight",
470
+ describe: "The word's weight against the others in its map.",
471
+ }),
472
+ }),
473
+ ),
474
+ Object.freeze({
475
+ name: "height",
476
+ kind: "map",
477
+ shape: "a map of genders to height ranges",
478
+ required: true,
479
+ describe:
480
+ "Height ranges keyed by gender; a gender with no range draws from the span " +
481
+ "of every range stated.",
482
+ values: Object.freeze({
483
+ kind: "map",
484
+ shape: "a height range",
485
+ describe: "The shortest and tallest height drawn.",
486
+ fields: Object.freeze([
487
+ Object.freeze({
488
+ name: "min",
489
+ kind: "string",
490
+ required: true,
491
+ shape: "a height in metres or feet and inches",
492
+ describe: "The shortest height drawn.",
493
+ }),
494
+ Object.freeze({
495
+ name: "max",
496
+ kind: "string",
497
+ required: true,
498
+ shape: "a height in metres or feet and inches",
499
+ describe: "The tallest height drawn.",
500
+ }),
501
+ ]),
502
+ }),
503
+ }),
504
+ ]),
505
+ });
506
+
507
+ /**
508
+ * A language's `data.names`: the pattern its speakers' names are built by and
509
+ * the lists each part is drawn from. Its keys beyond `pattern` and `given` are
510
+ * named by the language, so the map declares no closed inner keys and
511
+ * {@link checkLanguageNames} checks the whole of it; what the lists mean and
512
+ * how `package-build npc new` draws from them is in `language-names.mjs`.
513
+ *
514
+ * @type {DataFieldSpec}
515
+ */
516
+ const NAMES_FIELD = Object.freeze({
517
+ name: "names",
518
+ kind: "map",
519
+ shape: "a language's name lists",
520
+ check: (note) => checkLanguageNames(note, BEING_LOOK_VOCABULARY),
521
+ describe:
522
+ "How this language's speakers are named, for `package-build npc new`: `pattern` " +
523
+ 'builds a name from placeholders such as "{given} {clan}" and literal text, `given` ' +
524
+ "maps a gender to a list of given names, " +
525
+ `${wordList([...requiredGivenGenders()]).replace(" or ", " and ")} required, and ` +
526
+ "every other key is a list of names a placeholder of the same name draws from. A " +
527
+ "language skill note only. See [name lists](format-details.md#name-lists).",
528
+ fields: Object.freeze([
529
+ Object.freeze({
530
+ name: "pattern",
531
+ kind: "string-list-or-map",
532
+ required: true,
533
+ shape: 'a pattern such as "{given} {clan}", a list of them, or a map of genders to either',
534
+ describe:
535
+ "How a name is built: each `{list}` placeholder is filled from the list of " +
536
+ "that name, `{given.<gender>}` from that gender's given names, and the text " +
537
+ "around them is written as it stands. A list states every form a name may " +
538
+ "take; a map keyed by gender states each gender's forms, and a gender with " +
539
+ "none takes every pattern stated.",
540
+ entries: Object.freeze({
541
+ kind: "string",
542
+ shape: "a pattern",
543
+ describe: "One form a name may take.",
544
+ }),
545
+ values: Object.freeze({
546
+ kind: "string-or-list",
547
+ shape: "a pattern or a list of them",
548
+ describe: "The forms a name of that gender may take.",
549
+ entries: Object.freeze({
550
+ kind: "string",
551
+ shape: "a pattern",
552
+ describe: "One form a name of that gender may take.",
553
+ }),
554
+ }),
555
+ }),
556
+ Object.freeze({
557
+ name: "given",
558
+ kind: "map",
559
+ required: true,
560
+ shape: "a map of genders to lists of names",
561
+ describe:
562
+ "Given names by gender word. A gender with no list draws from every given " +
563
+ "list together.",
564
+ values: Object.freeze({
565
+ kind: "list",
566
+ shape: "a list of names",
567
+ describe: "The given names of one gender.",
568
+ entries: Object.freeze({
569
+ kind: "string",
570
+ shape: "a name",
571
+ describe: "One name, as written.",
572
+ }),
573
+ }),
574
+ }),
575
+ ]),
576
+ values: Object.freeze({
577
+ kind: "list",
578
+ shape: "a list of names",
579
+ describe: "A list a placeholder of the same name draws from, such as `clan` or `family`.",
580
+ entries: Object.freeze({
581
+ kind: "string",
582
+ shape: "a name",
583
+ describe: "One name, as written.",
584
+ }),
585
+ }),
586
+ });
587
+
432
588
  /** A list. */
433
589
  const LIST = Object.freeze({ shape: "list", kind: "list" });
434
590
 
@@ -1659,6 +1815,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1659
1815
  accepts: ["skill"],
1660
1816
  describe: "The skill this one specialises.",
1661
1817
  },
1818
+ NAMES_FIELD,
1662
1819
  ]),
1663
1820
  }),
1664
1821
 
@@ -1833,7 +1990,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1833
1990
  // A lore note is prose, and what it *is* about is its subType — with
1834
1991
  // two exceptions. A calendar is a division of the year, and a division
1835
1992
  // is data; a work of literature states in what tongue it is and what it
1836
- // concerns. The checks scope each family to the subType that means it,
1993
+ // concerns, and a culture what tongue it speaks and what its people look
1994
+ // like. The checks scope each family to the subType that means it,
1837
1995
  // because `DataFieldSpec` declares the keys a type accepts and not the
1838
1996
  // subType that may write them. Any lore note may name the people it
1839
1997
  // belongs to; a culture note names only itself.
@@ -1844,6 +2002,15 @@ export const NOTE_VOCABULARY = Object.freeze({
1844
2002
  ],
1845
2003
  data: Object.freeze([
1846
2004
  ...CALENDAR_FIELDS,
2005
+ {
2006
+ name: "parents",
2007
+ ...LINKS,
2008
+ ref: "lore",
2009
+ accepts: ["lore"],
2010
+ check: checkCultureParents,
2011
+ describe:
2012
+ "Culture only: parent culture lore notes, searched in listed depth-first order for omitted language and appearance profile fields.",
2013
+ },
1847
2014
  {
1848
2015
  ...CULTURE_FIELD,
1849
2016
  describe:
@@ -1852,7 +2019,9 @@ export const NOTE_VOCABULARY = Object.freeze({
1852
2019
  "culture, and states the field only as itself.",
1853
2020
  },
1854
2021
  ...LITERATURE_FIELDS,
2022
+ LANGUAGE_FIELD,
1855
2023
  EVENTS_FIELD,
2024
+ APPEARANCE_PROFILE_FIELD,
1856
2025
  ]),
1857
2026
  }),
1858
2027