@heroiclands/package-build 23.3.1 → 23.4.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.
@@ -0,0 +1,301 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ import { isDeepStrictEqual } from "node:util";
15
+ import { canonicalKey, parseAddress, renderAddress } from "./address.mjs";
16
+ import { positionOfFrontmatterPath, formatDiagnostic } from "./diagnostics.mjs";
17
+
18
+ const fields = ["language", "appearanceProfile"];
19
+ const payload = (note) => note.fm ?? note;
20
+ const dataOf = (note) => payload(note).data ?? payload(note);
21
+ const culture = (note) => payload(note).type === "lore" && payload(note).subType === "culture";
22
+ const parentsOf = (note) => (Array.isArray(dataOf(note).parents) ? dataOf(note).parents : []);
23
+
24
+ /** Read a culture parent against the field's Address defaults. */
25
+ function address(value, index) {
26
+ const tuple = parseAddress(value, {
27
+ package: index.contentPackage,
28
+ system: "note",
29
+ type: "lore",
30
+ types: index.types,
31
+ packages: index.packages,
32
+ });
33
+ return tuple.reason ? undefined : tuple;
34
+ }
35
+
36
+ /** The authored edge graph, with every cyclic edge identified. */
37
+ function graph(index) {
38
+ const out = new Map();
39
+ const queue = [...(index.notes ?? [])].filter(culture);
40
+ while (queue.length) {
41
+ const note = queue.pop();
42
+ if (out.has(note)) continue;
43
+ const edges = [];
44
+ out.set(note, edges);
45
+ parentsOf(note).forEach((value, position) => {
46
+ const tuple = address(value, index);
47
+ if (!tuple || tuple.type !== "lore") return;
48
+ const target = index.addressHit?.(renderAddress(tuple));
49
+ if (!target || !culture(target)) return;
50
+ edges.push({ target, position });
51
+ queue.push(target);
52
+ });
53
+ }
54
+ const reaches = (start, end) => {
55
+ const seen = new Set();
56
+ const queue = [start];
57
+ while (queue.length) {
58
+ const current = queue.pop();
59
+ if (current === end) return true;
60
+ if (seen.has(current)) continue;
61
+ seen.add(current);
62
+ queue.push(...(out.get(current) ?? []).map((edge) => edge.target));
63
+ }
64
+ return false;
65
+ };
66
+ const cyclic = new Map();
67
+ for (const [note, edges] of out)
68
+ cyclic.set(
69
+ note,
70
+ new Set(
71
+ edges.filter((edge) => reaches(edge.target, note)).map((edge) => edge.position),
72
+ ),
73
+ );
74
+ return { out, cyclic };
75
+ }
76
+
77
+ /** A located hierarchy finding with the Address of the culture it concerns. */
78
+ function finding(note, index, path, message) {
79
+ const fm = payload(note);
80
+ return {
81
+ file: note.file,
82
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "parents", ...path]),
83
+ severity: "error",
84
+ code: "culture-parents",
85
+ cultureAddress: canonicalKey(index.contentPackage, "note", "lore", fm.shortcode),
86
+ parentIndex: path[0],
87
+ message,
88
+ };
89
+ }
90
+
91
+ /** Validate subtype scope and culture targets; unresolved targets use the ordinary reference check. */
92
+ function parentFindings(note, index, analysis, unresolved) {
93
+ const data = dataOf(note);
94
+ if (data.parents === undefined || data.parents === null) return [];
95
+ if (!culture(note))
96
+ return [finding(note, index, [], "data.parents is stated by only culture lore notes")];
97
+ const findings = [];
98
+ parentsOf(note).forEach((value, position) => {
99
+ const tuple = address(value, index);
100
+ if (!tuple) return;
101
+ const key = renderAddress(tuple);
102
+ const target = tuple.type === "lore" ? index.addressHit?.(key) : undefined;
103
+ if (tuple.type !== "lore") {
104
+ if (unresolved)
105
+ findings.push(
106
+ finding(
107
+ note,
108
+ index,
109
+ [position],
110
+ `data.parents.${position} accepts lore, not ${tuple.type}`,
111
+ ),
112
+ );
113
+ } else if (!target) {
114
+ if (unresolved)
115
+ findings.push(
116
+ finding(
117
+ note,
118
+ index,
119
+ [position],
120
+ `data.parents.${position} names ${key}, which does not resolve in this package or its declared dependencies`,
121
+ ),
122
+ );
123
+ } else if (!culture(target))
124
+ findings.push(
125
+ finding(
126
+ note,
127
+ index,
128
+ [position],
129
+ `data.parents.${position} names ${key}, whose subType is not culture`,
130
+ ),
131
+ );
132
+ else if (analysis.cyclic.get(note)?.has(position))
133
+ findings.push(
134
+ finding(
135
+ note,
136
+ index,
137
+ [position],
138
+ `data.parents.${position} forms a culture parent cycle; a culture is never its own ancestor`,
139
+ ),
140
+ );
141
+ });
142
+ return findings;
143
+ }
144
+
145
+ const graphs = new WeakMap();
146
+ const hierarchyProblems = new WeakMap();
147
+ const inheritedFields = new WeakMap();
148
+ const inheritedSources = new WeakMap();
149
+
150
+ /** The authored source of one inherited field, retained outside the published record. */
151
+ export function cultureFieldSource(records, key, field) {
152
+ const record = records.find(
153
+ (entry) =>
154
+ entry.type === "lore" &&
155
+ entry.subType === "culture" &&
156
+ canonicalKey(entry.package, "note", "lore", entry.shortcode) === key,
157
+ );
158
+ return record && inheritedSources.get(record)?.get(field);
159
+ }
160
+
161
+ /** Fields supplied by an ancestor rather than authored on this record. */
162
+ export function cultureInheritedFields(record) {
163
+ return inheritedFields.get(record);
164
+ }
165
+
166
+ /** Read the hierarchy findings retained beside a resolved index record. */
167
+ export function cultureRecordProblems(record) {
168
+ return hierarchyProblems.get(record);
169
+ }
170
+
171
+ /** Read hierarchy findings attached to one culture's resolved index record. */
172
+ export function cultureHierarchyProblems(records, key) {
173
+ return records.flatMap((record) =>
174
+ (
175
+ record.type === "lore" &&
176
+ record.subType === "culture" &&
177
+ canonicalKey(record.package, "note", "lore", record.shortcode) === key
178
+ ) ?
179
+ (hierarchyProblems.get(record) ?? [])
180
+ : [],
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Check the declared culture-parent field in the normal frontmatter lint.
186
+ * @param {object} note - The note's frontmatter and source location.
187
+ * @param {{index?: object}} [options] - The corpus and its Address lookup.
188
+ * @returns {object[]} Located findings.
189
+ */
190
+ export function checkCultureParents(note, { index } = {}) {
191
+ if (note.reportedCultureParents) return [];
192
+ const context = index ?? { contentPackage: undefined, notes: [note] };
193
+ let analysis = graphs.get(context);
194
+ if (!analysis) {
195
+ analysis = graph(context);
196
+ graphs.set(context, analysis);
197
+ }
198
+ return parentFindings(note, context, analysis, false);
199
+ }
200
+
201
+ /**
202
+ * Resolve each omitted culture field independently, searching parents in listed depth-first order.
203
+ * Same-depth disagreement withholds the inherited field and produces a finding.
204
+ * @param {object} index - The authored culture nodes and Address lookup.
205
+ * @returns {{values: Map<object, object>, findings: object[]}}
206
+ */
207
+ export function cultureInheritance(index) {
208
+ const analysis = graph(index);
209
+ const findings = [];
210
+ const values = new Map();
211
+ for (const note of index.notes ?? []) {
212
+ if (!culture(note)) continue;
213
+ findings.push(...parentFindings(note, index, analysis, true));
214
+ const inherited = {};
215
+ const sources = new Map();
216
+ for (const field of fields) {
217
+ if (dataOf(note)[field] !== undefined) continue;
218
+ const candidates = [];
219
+ const work = [{ node: note, depth: 0 }];
220
+ const seen = new Map();
221
+ while (work.length) {
222
+ const { node, depth, via } = work.pop();
223
+ if (analysis.cyclic.get(node)?.size) continue;
224
+ if (seen.has(node) && seen.get(node) <= depth) continue;
225
+ seen.set(node, depth);
226
+ const value = dataOf(node)[field];
227
+ if (depth && value !== undefined) {
228
+ candidates.push({ value, depth, via, node });
229
+ continue;
230
+ }
231
+ const edges = analysis.out.get(node) ?? [];
232
+ for (let i = edges.length - 1; i >= 0; i--) {
233
+ const edge = edges[i];
234
+ if (!analysis.cyclic.get(node)?.has(edge.position))
235
+ work.push({
236
+ node: edge.target,
237
+ depth: depth + 1,
238
+ via: depth ? via : edge.position,
239
+ });
240
+ }
241
+ }
242
+ const atDepth = new Map();
243
+ let conflicting;
244
+ for (const candidate of candidates) {
245
+ if (
246
+ atDepth.has(candidate.depth) &&
247
+ !isDeepStrictEqual(atDepth.get(candidate.depth), candidate.value)
248
+ )
249
+ conflicting = candidate;
250
+ else atDepth.set(candidate.depth, candidate.value);
251
+ }
252
+ if (conflicting)
253
+ findings.push(
254
+ finding(
255
+ note,
256
+ index,
257
+ [conflicting.via],
258
+ `culture parents at the same depth disagree on data.${field}; state the field on this culture`,
259
+ ),
260
+ );
261
+ else if (candidates.length) {
262
+ inherited[field] = candidates[0].value;
263
+ const source = candidates[0].node;
264
+ sources.set(field, { file: source.file, raw: source.raw, fm: payload(source) });
265
+ }
266
+ }
267
+ inheritedSources.set(payload(note), sources);
268
+ values.set(note, inherited);
269
+ }
270
+ for (const note of index.notes ?? []) {
271
+ if (!culture(note)) continue;
272
+ inheritedFields.set(payload(note), new Set(Object.keys(values.get(note) ?? {})));
273
+ const relevant = new Set();
274
+ const work = [note];
275
+ while (work.length) {
276
+ const current = work.pop();
277
+ if (relevant.has(current)) continue;
278
+ relevant.add(current);
279
+ work.push(...(analysis.out.get(current) ?? []).map((edge) => edge.target));
280
+ }
281
+ const addresses = new Set(
282
+ [...relevant].map((entry) =>
283
+ canonicalKey(index.contentPackage, "note", "lore", payload(entry).shortcode),
284
+ ),
285
+ );
286
+ hierarchyProblems.set(
287
+ payload(note),
288
+ findings.filter((problem) => addresses.has(problem.cultureAddress)),
289
+ );
290
+ }
291
+ return { values, findings };
292
+ }
293
+
294
+ /** Emit or collect all findings from culture inheritance. */
295
+ export function reportCultureParents(findings, problems) {
296
+ if (problems) problems.push(...findings);
297
+ else if (findings.length)
298
+ throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
299
+ located: true,
300
+ });
301
+ }
@@ -185,7 +185,8 @@ export function mapEntriesOf(value) {
185
185
  *
186
186
  * @param {readonly object[]} fields - The type's `data:` declaration.
187
187
  * @returns {object[]} One spec per top-level key, a dotted group as a `map`
188
- * whose `fields` are its members, with `grouped: true`.
188
+ * whose `fields` are its members, with `grouped: true`, and each member
189
+ * marked `kindChecked`.
189
190
  */
190
191
  export function topLevelSpecs(fields) {
191
192
  const out = [];
@@ -202,7 +203,10 @@ export function topLevelSpecs(fields) {
202
203
  groups.set(head, group);
203
204
  out.push(group);
204
205
  }
205
- group.members.push({ ...field, name: rest.join(".") });
206
+ // The frontmatter lint's container check has already held a dotted
207
+ // field's leaf to its kind; `kindChecked` keeps the walk from saying
208
+ // so a second time.
209
+ group.members.push({ ...field, name: rest.join("."), kindChecked: true });
206
210
  }
207
211
  for (const group of groups.values()) {
208
212
  group.fields = topLevelSpecs(group.members);
@@ -285,6 +289,7 @@ export function checkDataKeys(note, fields) {
285
289
  const visit = (spec, value, path) => {
286
290
  if (value === undefined || value === null) return;
287
291
  if (!fitsKind(value, spec.kind)) {
292
+ if (spec.kindChecked && !spec.fields) return;
288
293
  findings.push(
289
294
  finding(
290
295
  path,
@@ -29,11 +29,39 @@ function* markdownFiles(root) {
29
29
 
30
30
  /** Preserve offsets while excluding literal examples from link scanning. */
31
31
  export function maskMarkdownCode(source, spans = true) {
32
- const pattern =
33
- spans ?
34
- /```[\s\S]*?```|~~~[\s\S]*?~~~|``[^`]*``|`[^`]*`/g
35
- : /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
36
- return source.replace(pattern, (match) => match.replace(/[^\n]/g, " "));
32
+ const mask = (value) => value.replace(/[^\n]/g, " ");
33
+ let fence;
34
+ let body = "";
35
+ for (const line of source.match(/[^\n]*\n|[^\n]+$/g) || []) {
36
+ if (fence) {
37
+ body += mask(line);
38
+ const close = line.match(/^ {0,3}(`+|~+)[ \t]*\r?\n?$/);
39
+ if (close && close[1][0] === fence[0] && close[1].length >= fence.length)
40
+ fence = undefined;
41
+ } else {
42
+ const open = line.match(/^ {0,3}(`{3,}|~{3,})(.*)/);
43
+ if (open && (open[1][0] !== "`" || !open[2].includes("`"))) {
44
+ fence = open[1];
45
+ body += mask(line);
46
+ } else body += line;
47
+ }
48
+ }
49
+ if (!spans) return body;
50
+ const delimiters = [...body.matchAll(/`+/g)];
51
+ let offset = 0;
52
+ let result = "";
53
+ for (let i = 0; i < delimiters.length; i++) {
54
+ const start = delimiters[i];
55
+ const end = delimiters.findIndex(
56
+ (candidate, index) => index > i && candidate[0].length === start[0].length,
57
+ );
58
+ if (end < 0) continue;
59
+ const stop = delimiters[end].index + delimiters[end][0].length;
60
+ result += body.slice(offset, start.index) + mask(body.slice(start.index, stop));
61
+ offset = stop;
62
+ i = end;
63
+ }
64
+ return result + body.slice(offset);
37
65
  }
38
66
 
39
67
  /** GitHub heading slug, retaining separate spaces after punctuation removal. */
@@ -50,11 +78,16 @@ export function docHeadingSlug(value) {
50
78
  export function docAnchors(source) {
51
79
  const body = maskMarkdownCode(source, false);
52
80
  const anchors = new Set();
81
+ const suffixes = new Map();
53
82
  for (const [, heading] of body.matchAll(/^#{1,6}\s+(.+?)\s*#*$/gm)) {
54
83
  const explicit = splitHeadingAttributes(heading);
55
- anchors.add(
56
- explicit.id || docHeadingSlug(explicit.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")),
57
- );
84
+ const base =
85
+ explicit.id || docHeadingSlug(explicit.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"));
86
+ let anchor = base;
87
+ let suffix = suffixes.get(base) || 0;
88
+ while (anchors.has(anchor)) anchor = `${base}-${++suffix}`;
89
+ suffixes.set(base, suffix);
90
+ anchors.add(anchor);
58
91
  }
59
92
  for (const [, anchor] of body.matchAll(/<a\s[^>]*(?:id|name)="([^"]+)"/g)) {
60
93
  anchors.add(anchor);
@@ -412,6 +412,7 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
412
412
  }
413
413
 
414
414
  for (const field of fields) {
415
+ if (note.inheritedCultureFields?.has(field.name)) continue;
415
416
  if (!field.kind) continue;
416
417
  const segments = field.name.split(".");
417
418
  let value = entries;
@@ -457,7 +458,12 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
457
458
 
458
459
  // One level down and every level below: each field's value is closed to
459
460
  // the inner keys its declaration states, as the container is to its own.
460
- findings.push(...checkDataKeys(note, fields));
461
+ findings.push(
462
+ ...checkDataKeys(
463
+ note,
464
+ fields.filter((field) => !note.inheritedCultureFields?.has(field.name)),
465
+ ),
466
+ );
461
467
 
462
468
  return findings;
463
469
  }
@@ -600,6 +606,13 @@ function checkDataReferences(note, field, value, segments, context, index) {
600
606
  : `; write \`${slot.declines}\` to decline the image on purpose`);
601
607
  }
602
608
  if (!reason) continue;
609
+ if (
610
+ note.fm?.type === "lore" &&
611
+ note.fm?.subType === "culture" &&
612
+ field.name === "parents" &&
613
+ note.reportedCultureParents?.has(check.path[0])
614
+ )
615
+ continue;
603
616
  const path = ["data", ...segments, ...check.path];
604
617
  findings.push({
605
618
  file: note.file,
@@ -1526,6 +1539,7 @@ export function lintNote(
1526
1539
  // way a wikilink would. The linter names no such field itself; the
1527
1540
  // vocabulary does, beside the field.
1528
1541
  for (const field of fields) {
1542
+ if (note.inheritedCultureFields?.has(field.name)) continue;
1529
1543
  if (typeof field.check !== "function") continue;
1530
1544
  findings.push(...field.check(note, { index }));
1531
1545
  }
@@ -506,15 +506,15 @@ export function humanizeValue(value) {
506
506
  /**
507
507
  * Several enumerated values as one clause — `weathered, ruddy and scarred`.
508
508
  *
509
- * A field that admits one value or a list of them reads as a single phrase, so
510
- * the noun it qualifies is said once rather than once per value. Internal: the
511
- * phrase that wants it is declared in this module.
509
+ * A list field reads as a single phrase, so the noun it qualifies is said
510
+ * once rather than once per value. Internal: the phrase that wants it is
511
+ * declared in this module.
512
512
  *
513
- * @param {unknown} value - One value, or a list of them.
513
+ * @param {unknown} value - A list of values.
514
514
  * @returns {string} The text.
515
515
  */
516
516
  function humanizedList(value) {
517
- const parts = (Array.isArray(value) ? value : [value])
517
+ const parts = (Array.isArray(value) ? value : [])
518
518
  .filter((entry) => entry !== undefined && entry !== null && entry !== "")
519
519
  .map((entry) => humanizeValue(entry));
520
520
  if (parts.length < 2) return parts[0] ?? "";
@@ -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,11 +111,13 @@ 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 { checkCultureParents } from "./culture-parents.mjs";
114
115
  import {
115
116
  checkAppearanceProfile,
116
117
  profileLookKeys,
117
118
  requiredHeightGenders,
118
119
  } from "./appearance-profile.mjs";
120
+ import { checkExtraFeatures } from "./appearance-features.mjs";
119
121
  import { LANGUAGE_FIELD, LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
120
122
  import { checkLanguageNames, requiredGivenGenders } from "./language-names.mjs";
121
123
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
@@ -271,9 +273,9 @@ function lookWords(rows) {
271
273
  *
272
274
  * Each field is closed to its list: any other value, an empty one included, is
273
275
  * a finding at the value naming the words it takes. `appearance.complexion`
274
- * takes one word or a list of them, and each entry is checked on its own.
275
- * `appearance.extra_features` is not here: it is free text, a list of the marks
276
- * no word covers.
276
+ * is a list of words, each entry checked on its own. `appearance.extra_features`
277
+ * is not here: it is a list of the physical marks no word covers, held to the
278
+ * rules of `appearance-features.mjs`.
277
279
  *
278
280
  * @type {Readonly<Record<string, readonly LookWord[]>>}
279
281
  */
@@ -595,15 +597,18 @@ const TEXT_LIST = Object.freeze({
595
597
  });
596
598
 
597
599
  /**
598
- * One value, or several.
599
- *
600
- * The two are the same fact at different lengths rather than two different
601
- * facts, so a single value means a list of one and a consumer reads one shape.
600
+ * A being's complexion: a list of complexion words, `[]` included. The shape
601
+ * names the list form, because the finding an author meets for a single word
602
+ * quotes it.
602
603
  */
603
- const TEXT_OR_LIST = Object.freeze({
604
- shape: "string or list",
605
- kind: "string-or-list",
606
- entries: Object.freeze({ kind: "string", shape: "a string" }),
604
+ const COMPLEXION_LIST = Object.freeze({
605
+ shape: "a list of words, such as `[weathered]` or `[]`",
606
+ kind: "list",
607
+ entries: Object.freeze({
608
+ kind: "string",
609
+ shape: "a string",
610
+ allowed: lookValues("appearance.complexion"),
611
+ }),
607
612
  });
608
613
 
609
614
  /** A single Address. */
@@ -1453,22 +1458,26 @@ export const NOTE_VOCABULARY = Object.freeze({
1453
1458
  lookField("appearance.skin_color", "Skin colour"),
1454
1459
  {
1455
1460
  ...lookField("appearance.complexion", "The skin's condition"),
1456
- ...TEXT_OR_LIST,
1461
+ ...COMPLEXION_LIST,
1462
+ required: true,
1457
1463
  describe:
1458
- `The skin's condition: ${wordList(lookValues("appearance.complexion"))}, ` +
1459
- "one word or a list of several, because a face carries more than one at " +
1460
- "once. A single word means a list of one. See " +
1464
+ `The skin's condition, as a list of ${wordList(lookValues("appearance.complexion"))}: ` +
1465
+ "`[weathered, freckled]`, `[clear]` for unremarked skin, or `[]` where no " +
1466
+ "condition is established. Required once a being states an appearance; a " +
1467
+ "single word outside a list is an error. See " +
1461
1468
  "[a being's look](format-details.md#a-beings-look) for what each word means.",
1462
- entries: Object.freeze({
1463
- kind: "string",
1464
- shape: "a string",
1465
- allowed: lookValues("appearance.complexion"),
1466
- }),
1467
1469
  },
1468
1470
  {
1469
1471
  name: "appearance.extra_features",
1470
1472
  ...TEXT_LIST,
1471
- describe: "Anything else a stranger would notice, in free text.",
1473
+ check: (note) => checkExtraFeatures(note, BEING_LOOK_VOCABULARY),
1474
+ describe:
1475
+ "The visible physical marks no word covers, each a short plain description " +
1476
+ "of one: `a burn scar on the left forearm`, `a missing finger`. An entry " +
1477
+ "stating a hair, eye or skin colour or a skin condition, gear or clothing, a " +
1478
+ "voice, behaviour or manner, an impression, handedness, sterility or an " +
1479
+ "apparent age is an error, as is any punctuation but a hyphen or apostrophe " +
1480
+ "inside a word. See [a being's look](format-details.md#a-beings-look).",
1472
1481
  },
1473
1482
  ]),
1474
1483
  }),
@@ -2001,6 +2010,15 @@ export const NOTE_VOCABULARY = Object.freeze({
2001
2010
  ],
2002
2011
  data: Object.freeze([
2003
2012
  ...CALENDAR_FIELDS,
2013
+ {
2014
+ name: "parents",
2015
+ ...LINKS,
2016
+ ref: "lore",
2017
+ accepts: ["lore"],
2018
+ check: checkCultureParents,
2019
+ describe:
2020
+ "Culture only: parent culture lore notes, searched in listed depth-first order for omitted language and appearance profile fields.",
2021
+ },
2004
2022
  {
2005
2023
  ...CULTURE_FIELD,
2006
2024
  describe:
@@ -40,7 +40,8 @@
40
40
  * being in the package already holds it.
41
41
  * - `type: being`, `subType: npc`, `tags: [generated, draft]`.
42
42
  * - `frame`, and `appearance.eye_color`, `hair_color`, `skin_color` and
43
- * `complexion` — weighted draws from the culture's appearance profile.
43
+ * `complexion` — weighted draws from the culture's appearance profile;
44
+ * `complexion` is a list of the one word drawn.
44
45
  * - `height` — from the profile's range for the gender, in feet and inches.
45
46
  * - `weight` — from the height and the frame's body-mass band, in pounds.
46
47
  * - `born` — an adult age of {@link ADULT_AGE} years before the package's
@@ -346,8 +347,7 @@ export function appearanceParagraph({ name, age, gender, height, frame, appearan
346
347
  const sentences = [`${first}.`];
347
348
 
348
349
  const look = appearance ?? {};
349
- const complexion = [look.complexion ?? []]
350
- .flat()
350
+ const complexion = (Array.isArray(look.complexion) ? look.complexion : [])
351
351
  .filter(Boolean)
352
352
  .map((w) => wordLabel("appearance.complexion", w));
353
353
  const parts = [
@@ -491,7 +491,8 @@ export function generateNpc(spec, context) {
491
491
  const appearance = { ...look };
492
492
  for (const key of profileLookKeys(BEING_LOOK_VOCABULARY)) {
493
493
  if (key === "frame" || given(look[key])) continue;
494
- appearance[key] = weighted(profile[key], draw(`data.appearance.${key}`));
494
+ const word = weighted(profile[key], draw(`data.appearance.${key}`));
495
+ appearance[key] = key === "complexion" ? [word] : word;
495
496
  }
496
497
  if (!given(look.extra_features)) appearance.extra_features = [];
497
498
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.3.1",
3
+ "version": "23.4.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -191,8 +191,9 @@
191
191
  "build:asset-index": "node ci/emit-asset-index.mjs",
192
192
  "format": "prettier --write .",
193
193
  "format:check": "prettier --check .",
194
- "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:image-roles lint:banners lint:content-format",
194
+ "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:image-roles lint:banners lint:content-format lint:docs-links",
195
195
  "lint:markdown": "node bin/package-build.mjs markdown",
196
+ "lint:docs-links": "node bin/package-build.mjs docs links",
196
197
  "lint:yaml": "node bin/package-build.mjs yaml",
197
198
  "lint:labels": "node bin/package-build.mjs labels check",
198
199
  "lint:image-roles": "vitest run tests/theme-image-roles.test.ts",