@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 +6 -0
- package/bin/content-commands.mjs +19 -4
- package/docs/commands.md +8 -0
- package/docs/content-format.md +7 -0
- package/docs/reference/format-details.md +111 -6
- package/docs/reference/note-types.md +1 -0
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +22 -0
- package/engine/content-links.mjs +9 -0
- package/engine/culture-parents.mjs +301 -0
- package/engine/frontmatter-lint.mjs +15 -1
- package/engine/metadata-index.mjs +11 -0
- package/engine/note-vocabulary.mjs +10 -0
- package/package.json +1 -1
- package/types/engine/culture-parents.d.mts +29 -0
package/CHANGELOG.md
CHANGED
package/bin/content-commands.mjs
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/docs/content-format.md
CHANGED
|
@@ -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
|
|
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,
|
|
5010
|
-
only on a `literature` note
|
|
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
|
|
5015
|
-
|
|
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`. |
|
package/engine/content-index.mjs
CHANGED
|
@@ -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
|
package/engine/content-links.mjs
CHANGED
|
@@ -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(
|
|
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.
|
|
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;
|