@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.
- package/CHANGELOG.md +14 -0
- package/bin/content-commands.mjs +19 -4
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/commands.md +13 -2
- package/docs/content-format.md +17 -10
- package/docs/guides/release-and-deploy.md +1 -1
- package/docs/reference/format-details.md +150 -14
- package/docs/reference/note-types.md +28 -27
- package/docs/reference/theme-pages.md +5 -4
- package/engine/appearance-features.mjs +418 -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/data-keys.mjs +7 -2
- package/engine/docs-checks.mjs +41 -8
- package/engine/frontmatter-lint.mjs +15 -1
- package/engine/infobox.mjs +5 -5
- package/engine/metadata-index.mjs +11 -0
- package/engine/note-vocabulary.mjs +39 -21
- package/engine/npc-generate.mjs +5 -4
- package/package.json +3 -2
- package/types/engine/appearance-features.d.mts +66 -0
- package/types/engine/culture-parents.d.mts +29 -0
- package/types/engine/data-keys.d.mts +2 -1
- package/types/engine/docs-checks.d.mts +1 -1
- package/types/engine/note-vocabulary.d.mts +3 -3
|
@@ -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
|
+
}
|
package/engine/data-keys.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/engine/docs-checks.mjs
CHANGED
|
@@ -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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/engine/infobox.mjs
CHANGED
|
@@ -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
|
|
510
|
-
*
|
|
511
|
-
*
|
|
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 -
|
|
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 : [
|
|
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
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
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
|
|
604
|
-
shape: "
|
|
605
|
-
kind: "
|
|
606
|
-
entries: Object.freeze({
|
|
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
|
-
...
|
|
1461
|
+
...COMPLEXION_LIST,
|
|
1462
|
+
required: true,
|
|
1457
1463
|
describe:
|
|
1458
|
-
`The skin's condition
|
|
1459
|
-
"
|
|
1460
|
-
"
|
|
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
|
-
|
|
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:
|
package/engine/npc-generate.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
+
"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",
|