@heroiclands/package-build 23.3.1 → 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,11 @@
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
+
3
9
  ## 23.3.1
4
10
 
5
11
  ### Patch Changes
@@ -141,6 +141,7 @@ import { profileFindings } from "../engine/appearance-profile.mjs";
141
141
  import { namesFindings } from "../engine/language-names.mjs";
142
142
  import { parseAddress, renderAddress } from "../engine/address.mjs";
143
143
  import { cultureKey, generateNpc, npcDefaults, npcSpecProblems } from "../engine/npc-generate.mjs";
144
+ import { cultureFieldSource, cultureHierarchyProblems } from "../engine/culture-parents.mjs";
144
145
 
145
146
  /**
146
147
  * The packs `unpack` extracts.
@@ -2613,11 +2614,12 @@ function newNpc(config, argv) {
2613
2614
  throw new Error(`${argv.out} already exists, and \`npc new\` never overwrites a note`);
2614
2615
 
2615
2616
  const root = config.paths.content;
2617
+ const corpusProblems = [];
2616
2618
  const records = indexRecordsFor({
2617
2619
  contentBase: root,
2618
2620
  config,
2619
2621
  skipDirectories: config.skipDirectories,
2620
- problems: [],
2622
+ problems: corpusProblems,
2621
2623
  });
2622
2624
  const index = buildLinkIndex(root, {
2623
2625
  config,
@@ -2643,6 +2645,12 @@ function newNpc(config, argv) {
2643
2645
  `\`data.culture\` ${JSON.stringify(written)} is not a culture: it names a ` +
2644
2646
  `${culture.type ?? hit.type ?? "note"} of subType ${culture.subType ?? "none"}`,
2645
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
+ }
2646
2654
  const cultureNote = located(hit);
2647
2655
  const profile = culture.data?.appearanceProfile;
2648
2656
  if (profile === undefined || profile === null)
@@ -2652,8 +2660,13 @@ function newNpc(config, argv) {
2652
2660
  `${key} states no \`data.appearanceProfile\`, so \`npc new\` cannot draw its ` +
2653
2661
  "people's look",
2654
2662
  );
2663
+ const profileSource = cultureFieldSource(records, key, "appearanceProfile") ?? hit;
2655
2664
  const profileErrors = profileFindings(
2656
- { file: cultureNote.file, raw: hit.raw, fm: culture },
2665
+ {
2666
+ file: located(profileSource).file,
2667
+ raw: profileSource.raw,
2668
+ fm: profileSource.fm ?? profileSource,
2669
+ },
2657
2670
  BEING_LOOK_VOCABULARY,
2658
2671
  );
2659
2672
  if (profileErrors.length) {
@@ -2669,6 +2682,8 @@ function newNpc(config, argv) {
2669
2682
  `${key} states no \`data.language\`, so \`npc new\` has no name lists to draw ` +
2670
2683
  "from; name the language skill note its people speak",
2671
2684
  );
2685
+ const languageSource = cultureFieldSource(records, key, "language") ?? hit;
2686
+ const languageSourceNote = located(languageSource);
2672
2687
  const tuple = parseAddress(tongue, {
2673
2688
  package: index.contentPackage,
2674
2689
  system: "note",
@@ -2680,14 +2695,14 @@ function newNpc(config, argv) {
2680
2695
  const languageHit = languageKey ? index.addressHit(languageKey) : undefined;
2681
2696
  if (!languageHit)
2682
2697
  throw noteError(
2683
- cultureNote,
2698
+ languageSourceNote,
2684
2699
  ["data", "language"],
2685
2700
  `${key}'s \`data.language\` names no note in this package or its dependencies`,
2686
2701
  );
2687
2702
  const language = languageHit.fm ?? languageHit;
2688
2703
  if (String(language.type ?? languageHit.type) !== "skill" || language.subType !== "language")
2689
2704
  throw noteError(
2690
- cultureNote,
2705
+ languageSourceNote,
2691
2706
  ["data", "language"],
2692
2707
  `${key}'s \`data.language\` ${languageKey} is not a language: it names a ` +
2693
2708
  `${language.type ?? languageHit.type ?? "note"} of subType ` +
package/docs/commands.md CHANGED
@@ -2828,6 +2828,14 @@ culture missing either, with an error at the culture note:
2828
2828
  `data.names` is an error at the language note. See
2829
2829
  [name lists](reference/format-details.md#name-lists).
2830
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
+
2831
2839
  A profile or name lists with problems are refused with a finding at each.
2832
2840
 
2833
2841
  A spec that states `name.full` keeps it, and the name must fit the language's
@@ -96,6 +96,13 @@ A being states its gender, frame and appearance in words from one closed vocabul
96
96
 
97
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
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
+
99
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).
100
107
 
101
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).
@@ -2715,6 +2715,104 @@ table would otherwise need: the authored `ORDER BY` decides the section order to
2715
2715
  **Beware `packFolder`.** It is a note's _pack_ folder, not its directory — the
2716
2716
  directory is `file.folder`.
2717
2717
 
2718
+ ##### Culture parents and inherited fields
2719
+
2720
+ A `lore` note of `subType: culture` may list the larger cultures it belongs to
2721
+ in `data.parents`. Each entry is an Address naming another culture lore note;
2722
+ a missing target, a target of another type or subtype, and a parent cycle are
2723
+ errors at the offending list entry. No other lore subtype accepts the key.
2724
+
2725
+ A culture inherits `data.language` and `data.appearanceProfile` independently
2726
+ when it leaves either field out. A stated value wins for that field. The
2727
+ profile is inherited as one complete object; its weights and ranges are not
2728
+ merged with another profile. Parents are searched in listed order, depth-first:
2729
+ the first parent's chain is searched before the next parent's chain. Sources
2730
+ at the same depth that disagree on a field produce a finding. Nothing is
2731
+ inherited through a broken parent address.
2732
+
2733
+ The emitted content-index JSONL carries the resolved fields in the child's
2734
+ `data`, exactly as if the child had stated them. `package-build npc new` and
2735
+ other consumers read those resolved values; they do not walk culture parents.
2736
+ The child's `data.culture` remains its own Address. A being, place or
2737
+ affiliation naming the parent in `data.culture` belongs to that named culture;
2738
+ it does not match the parent's children.
2739
+
2740
+ These complete notes give Riverfolk their parent culture's tongue and profile:
2741
+
2742
+ ```markdown
2743
+ ---
2744
+ shortcode: reedfolk
2745
+ name: { full: Reedfolk }
2746
+ type: lore
2747
+ subType: culture
2748
+ data:
2749
+ language: skill-reedtongue
2750
+ appearanceProfile:
2751
+ frame: { medium: 1 }
2752
+ eye_color: { brown: 1 }
2753
+ hair_color: { black: 1 }
2754
+ skin_color: { tawny: 1 }
2755
+ complexion: { clear: 1 }
2756
+ height:
2757
+ female: { min: 1.5m, max: 1.7m }
2758
+ male: { min: 1.6m, max: 1.8m }
2759
+ ---
2760
+
2761
+ Reedfolk share the reed tongue and live along the marsh's waterways. Their
2762
+ settlements trade by boat, hold seasonal gatherings and pass their stories
2763
+ between families at those gatherings.
2764
+ ```
2765
+
2766
+ ```markdown
2767
+ ---
2768
+ shortcode: riverfolk
2769
+ name: { full: Riverfolk }
2770
+ type: lore
2771
+ subType: culture
2772
+ data:
2773
+ parents: [lore-reedfolk]
2774
+ ---
2775
+
2776
+ Riverfolk belong to the Reedfolk culture and share its tongue. They live
2777
+ beside the river's deep channels, where their boats carry grain, timber and
2778
+ woven reeds between settlements.
2779
+ ```
2780
+
2781
+ The parent names this language note, whose lists supply generated names:
2782
+
2783
+ ```markdown
2784
+ ---
2785
+ shortcode: reedtongue
2786
+ name: { full: Reed Tongue }
2787
+ type: skill
2788
+ subType: language
2789
+ data:
2790
+ pack: skills
2791
+ names:
2792
+ pattern: "{given} {kin}"
2793
+ given:
2794
+ female: [Nara]
2795
+ male: [Taro]
2796
+ kin: [Mori]
2797
+ ---
2798
+
2799
+ The reed tongue is spoken beside the marsh's waterways. Its speakers use a
2800
+ given name followed by a family name, and carry those names between river
2801
+ settlements with their trade.
2802
+ ```
2803
+
2804
+ In a package named `reedflats`, the Riverfolk index record carries the Reedfolk
2805
+ profile and `data.language` as a published Address object:
2806
+
2807
+ ```json
2808
+ { "address": "reedflats-note-skill-reedtongue", "anchor": null, "anchorKind": null }
2809
+ ```
2810
+
2811
+ Adding `language: skill-rivertongue` to Riverfolk's `data` selects that language
2812
+ instead while retaining the inherited profile, provided the named language
2813
+ note exists. Stating an `appearanceProfile` there replaces the inherited
2814
+ profile while leaving the inherited tongue in place.
2815
+
2718
2816
  ##### Culture in the index
2719
2817
 
2720
2818
  `data.culture` in the index is the note's **resolved** culture, which is its
@@ -3737,8 +3835,9 @@ missing key, a missing `female` or `male` range, a height that does not read,
3737
3835
  a range whose `min` is above its `max`, and a `height` key that is not a
3738
3836
  gender.
3739
3837
 
3740
- `package-build npc new` refuses a culture that states no profile, naming the
3741
- culture note.
3838
+ `package-build npc new` refuses a culture with no resolved profile, naming the
3839
+ culture note. A culture may obtain the complete profile from its
3840
+ [parents](#culture-parents-and-inherited-fields).
3742
3841
 
3743
3842
  #### Name lists
3744
3843
 
@@ -5006,13 +5105,18 @@ data:
5006
5105
  The saga lists under **In song and story** on `place-ironfells`, which holds the
5007
5106
  sack, on `place-east` and on `lore-fords`.
5008
5107
 
5009
- Calendar fields are written only on a `calendar` note, and `form`, `subjects` and `language`
5010
- only on a `literature` note; written on any other lore subType, each is an error at its own key.
5108
+ Calendar fields are written only on a `calendar` note, `form` and `subjects`
5109
+ only on a `literature` note, `language` only on `literature` and `culture`, and
5110
+ `parents` only on `culture`. Written on any other lore subtype, each is an
5111
+ error at its own key.
5011
5112
  `culture` may be written on any lore subType. A culture note is itself the culture, so it
5012
5113
  leaves the field out or names itself, by its shortcode or by any longer form of its Address;
5013
5114
  naming any other note is an error at the value. A `lore` note that states no culture has
5014
- none, since a `lore` note has no `data.parents` to inherit through; a culture note reads as
5015
- its own Address in the index. See [Culture in the index](#culture-in-the-index).
5115
+ none; a culture note reads as its own Address in the index, including when it
5116
+ names parent cultures. Culture parents supply language and appearance fields,
5117
+ not a different cultural identity. See
5118
+ [Culture parents](#culture-parents-and-inherited-fields) and
5119
+ [Culture in the index](#culture-in-the-index).
5016
5120
 
5017
5121
  | `data` property | Values | Description |
5018
5122
  | ------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -5046,6 +5150,7 @@ its own Address in the index. See [Culture in the index](#culture-in-the-index).
5046
5150
  | `culture` | `Address` | The people this lore belongs to — for a `literature` note, the people whose work it is — naming a lore note with `subType: culture`; not on a `culture` note |
5047
5151
  | `form` | `string` | `literature` only: the kind of work in its people's own terms — `epic`, `saga`, `praise-song`, `elegy`. Free text |
5048
5152
  | `subjects` | `Address[]` | `literature` only: the beings, places, gods and other notes the work concerns, and any event, as `note#id` |
5153
+ | `parents` | `Address[]` | `culture` only: the larger cultures this one belongs to; each target is a culture lore note — see [culture parents](#culture-parents-and-inherited-fields) |
5049
5154
  | `language` | `Address` | `literature` and `culture` only: the tongue a work is composed in, or a culture's people speak, naming a skill note with `subType: language`; `package-build npc new` draws a culture's names from that language's [name lists](#name-lists) |
5050
5155
  | `events` | event entries — see [Events](#events) | What happened to or at this subject: dated, attributed events |
5051
5156
  | `appearanceProfile` | `{ frame, eye_color, hair_color, skin_color, complexion, height }` | `culture` only: what its people look like, for `package-build npc new` — see [appearance profiles](#appearance-profiles) |
@@ -533,6 +533,7 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
533
533
  | `data.namedDays` | list of `{ name, abbreviation?, day }` | Names assigned to particular days of the year. |
534
534
  | `data.eras` | list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }` | The year-counts kept in this calendar. A marker names one era and uses these months. |
535
535
  | `data.formats` | map of named Calendaria format strings | Named patterns for reading and writing this calendar's dates. |
536
+ | `data.parents` | list of Addresses | Culture only: parent culture lore notes, searched in listed depth-first order for omitted language and appearance profile fields. |
536
537
  | `data.culture` | an Address | The people this lore belongs to — for a work of literature, the people whose work it is — as a culture lore note. A culture note is its own culture, and states the field only as itself. |
537
538
  | `data.form` | string | The kind of work in its people's own terms — an epic, a saga, a praise-song, an elegy. Free text. |
538
539
  | `data.subjects` | list of Addresses | The beings, places, gods and other notes the work concerns, and each event it concerns, named as `place-ironfells#sack`. |
@@ -247,6 +247,7 @@ types:
247
247
  subTypes: []
248
248
  lore:
249
249
  data:
250
+ - parents
250
251
  - epoch
251
252
  - months
252
253
  - weekdays
@@ -86,6 +86,7 @@
86
86
  */
87
87
 
88
88
  import { isAddressSegment } from "./address-charset.mjs";
89
+ import { cultureInheritance, reportCultureParents } from "./culture-parents.mjs";
89
90
  import {
90
91
  formatDiagnostic,
91
92
  locateFrontmatterKey,
@@ -1178,6 +1179,27 @@ export function collectContentIndex(
1178
1179
  // is found by its canonical address. Before the sort, which reads nothing
1179
1180
  // the pass writes.
1180
1181
  resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
1182
+ const cultureNotes = records
1183
+ .filter((record) => isNoteRecord(record) && record.type === "lore")
1184
+ .map((record) => ({
1185
+ fm: record,
1186
+ file: noteFile(contentBase, record),
1187
+ raw: fs.readFileSync(noteFile(contentBase, record), "utf8"),
1188
+ }));
1189
+ const cultureByKey = new Map(
1190
+ cultureNotes.map((note) => [
1191
+ canonicalKey(contentPackage, "note", "lore", note.fm.shortcode),
1192
+ note,
1193
+ ]),
1194
+ );
1195
+ const inheritance = cultureInheritance({
1196
+ contentPackage,
1197
+ notes: cultureNotes,
1198
+ addressHit: (key) => cultureByKey.get(key) ?? foreignRecord?.(key),
1199
+ });
1200
+ reportCultureParents(inheritance.findings, problems);
1201
+ for (const [note, inherited] of inheritance.values)
1202
+ if (Object.keys(inherited).length) note.fm.data = { ...note.fm.data, ...inherited };
1181
1203
 
1182
1204
  // Sorted at every depth like a note's record, and for the same reason: the
1183
1205
  // declaration order of the `asset` fields is a fact about the emitter, not
@@ -55,6 +55,7 @@
55
55
  import { readWikilink } from "./wikilink-syntax.mjs";
56
56
  import { encodeAddresses } from "./address-values.mjs";
57
57
  import fs from "node:fs";
58
+ import { cultureRecordProblems, cultureInheritedFields } from "./culture-parents.mjs";
58
59
  import path from "node:path";
59
60
 
60
61
  import { matchAllOutsideCode } from "./code-fences.mjs";
@@ -224,6 +225,14 @@ export function buildLinkIndex(
224
225
  const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
225
226
  const type = record.type.toLowerCase();
226
227
  const note = { file: absPath, rel, fm, body, raw, type };
228
+ const hierarchyProblems = cultureRecordProblems(record);
229
+ note.inheritedCultureFields = cultureInheritedFields(record);
230
+ if (hierarchyProblems)
231
+ note.reportedCultureParents = new Set(
232
+ hierarchyProblems
233
+ .filter((problem) => problem.file === absPath)
234
+ .map((problem) => problem.parentIndex),
235
+ );
227
236
 
228
237
  for (const hit of frontmatterWikilinks(fm)) {
229
238
  frontmatterLinks.push({ note, ...hit });
@@ -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
+ }
@@ -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
  }
@@ -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,6 +111,7 @@ 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,
@@ -2001,6 +2002,15 @@ export const NOTE_VOCABULARY = Object.freeze({
2001
2002
  ],
2002
2003
  data: Object.freeze([
2003
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
+ },
2004
2014
  {
2005
2015
  ...CULTURE_FIELD,
2006
2016
  describe:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.3.1",
3
+ "version": "23.3.2",
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",
@@ -0,0 +1,29 @@
1
+ /** The authored source of one inherited field, retained outside the published record. */
2
+ export function cultureFieldSource(records: any, key: any, field: any): any;
3
+ /** Fields supplied by an ancestor rather than authored on this record. */
4
+ export function cultureInheritedFields(record: any): any;
5
+ /** Read the hierarchy findings retained beside a resolved index record. */
6
+ export function cultureRecordProblems(record: any): any;
7
+ /** Read hierarchy findings attached to one culture's resolved index record. */
8
+ export function cultureHierarchyProblems(records: any, key: any): any;
9
+ /**
10
+ * Check the declared culture-parent field in the normal frontmatter lint.
11
+ * @param {object} note - The note's frontmatter and source location.
12
+ * @param {{index?: object}} [options] - The corpus and its Address lookup.
13
+ * @returns {object[]} Located findings.
14
+ */
15
+ export function checkCultureParents(note: object, { index }?: {
16
+ index?: object;
17
+ }): object[];
18
+ /**
19
+ * Resolve each omitted culture field independently, searching parents in listed depth-first order.
20
+ * Same-depth disagreement withholds the inherited field and produces a finding.
21
+ * @param {object} index - The authored culture nodes and Address lookup.
22
+ * @returns {{values: Map<object, object>, findings: object[]}}
23
+ */
24
+ export function cultureInheritance(index: object): {
25
+ values: Map<object, object>;
26
+ findings: object[];
27
+ };
28
+ /** Emit or collect all findings from culture inheritance. */
29
+ export function reportCultureParents(findings: any, problems: any): void;