@heroiclands/package-build 23.3.1 → 23.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
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
+ /**
15
+ * **What a being's `data.appearance.extra_features` admits.**
16
+ *
17
+ * Each entry is a short plain description of one visible physical mark: a
18
+ * scar, a missing finger, a tattoo, a limp. The closed look fields carry
19
+ * everything else, so an entry is refused when it states:
20
+ *
21
+ * - **a colour or skin condition** — a hair colour beside the word `hair`, an
22
+ * eye colour beside `eye` or `eyes`, a skin colour beside `skin` or
23
+ * `complexion`, or a skin condition. These belong in `hair_color`,
24
+ * `eye_color`, `skin_color` and `complexion`. The colour and condition words
25
+ * are the being-look vocabulary's own, read at run time, plus the spellings
26
+ * in {@link COLOUR_VARIANTS} and {@link CONDITION_PHRASES}. A localised mark
27
+ * that only mentions skin — a burn scar on the forearm — is a mark;
28
+ * - **anything that is not a physical mark** — the categories of
29
+ * {@link NON_PHYSICAL_FEATURES}: gear, clothing or a weapon; a voice or
30
+ * speech; behaviour, manner or temperament; an impression; handedness; sterility; an
31
+ * apparent-age reading;
32
+ * - **punctuation** — any punctuation or symbol character, apart from a hyphen
33
+ * or an apostrophe standing between two letters (`ink-stained fingers`, `a
34
+ * lion's mane`). A long punctuated entry is several facts, or a labelled one,
35
+ * where a single mark belongs.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import { isPlainMap } from "./data-keys.mjs";
41
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
42
+
43
+ /** A vocabulary word as an author writes it: `dark_brown` is "dark brown" or "dark-brown". */
44
+ const phrase = (word) => word.replaceAll("_", "[ -]");
45
+
46
+ /** A case-insensitive whole-word alternation. */
47
+ const anyOf = (alternatives) => new RegExp(`\\b(?:${alternatives.join("|")})\\b`, "iu");
48
+
49
+ /**
50
+ * Hair and eye colour spellings the vocabulary does not take but an author
51
+ * writes, as regular-expression alternatives. Beside `hair` or `eyes` any of
52
+ * them, or any hair or eye colour word, states a colour.
53
+ */
54
+ export const COLOUR_VARIANTS = Object.freeze([
55
+ "gr[ae]y(?:ing|ed)?",
56
+ "blond",
57
+ "ginger",
58
+ "milky",
59
+ "yellow",
60
+ "gold(?:en)?",
61
+ "pink",
62
+ "orange",
63
+ "purple",
64
+ "copper",
65
+ "mixed",
66
+ "streaked",
67
+ "dyed",
68
+ ]);
69
+
70
+ /**
71
+ * Skin-condition phrasings that are not complexion words, as
72
+ * regular-expression alternatives. Any of them states a complexion.
73
+ */
74
+ export const CONDITION_PHRASES = Object.freeze([
75
+ "freckles",
76
+ "pox marks?",
77
+ "pocked",
78
+ "pitted skin",
79
+ "acne",
80
+ "pimples",
81
+ "sunburn",
82
+ "wrinkles",
83
+ "pallid",
84
+ "flushed",
85
+ "weather-beaten",
86
+ ]);
87
+
88
+ /**
89
+ * Complexion words with an everyday sense beyond the skin: refused when the
90
+ * entry is the word alone, or names the skin and describes no mark.
91
+ */
92
+ const BROAD_CONDITIONS = Object.freeze(["scarred", "rough", "clear", "smooth", "oily", "wan"]);
93
+
94
+ /** An entry describing one of these is a physical mark, whatever else it mentions. */
95
+ const MARK =
96
+ /\b(?:tattoos?|tatoo|scars?|scarred|scarring|scarification|brand(?:ed)?|birthmark|burn marks?|pox marks?)\b/iu;
97
+
98
+ const SKIN = /\b(?:skin|complexion|complected)\b/iu;
99
+
100
+ /**
101
+ * The terms that make an `extra_features` entry something other than a
102
+ * physical mark, by category, as case-insensitive whole-word patterns.
103
+ *
104
+ * `exemptMarks` lets an entry describing a tattoo, scar, brand or burn mark
105
+ * pass the category, so "a tattoo of a knight's helmet" stands while "wears a
106
+ * helmet" does not. The voice, impression, handedness, sterility and age
107
+ * categories admit no such exemption: "a beautiful tattoo of a crane" is
108
+ * refused.
109
+ *
110
+ * @type {ReadonlyArray<{rule: string, category: string, pattern: RegExp, exemptMarks: boolean}>}
111
+ */
112
+ export const NON_PHYSICAL_FEATURES = Object.freeze([
113
+ {
114
+ rule: "gear",
115
+ category: "gear, clothing or a weapon",
116
+ pattern: anyOf([
117
+ "wears?",
118
+ "worn",
119
+ "wearing",
120
+ "carr(?:ies|ied|ying)(?! (?:him|her|them|it)self)",
121
+ "cloak(?:ed|s)?",
122
+ "clothing",
123
+ "clothes",
124
+ "robes?",
125
+ "vestments",
126
+ "tunic",
127
+ "mail",
128
+ "armou?r",
129
+ "shirt",
130
+ "axe",
131
+ "sword",
132
+ "dagger",
133
+ "staff",
134
+ "bag",
135
+ "chain",
136
+ "cord",
137
+ "thong",
138
+ "boots?",
139
+ "hat",
140
+ "helm(?:et)?",
141
+ "ring",
142
+ "amulet",
143
+ "pendant",
144
+ "belt",
145
+ "weapons?",
146
+ "garb",
147
+ "dress(?:ed|es)?",
148
+ "gloves?",
149
+ ]),
150
+ exemptMarks: true,
151
+ },
152
+ {
153
+ rule: "voice",
154
+ category: "a voice or speech",
155
+ pattern: anyOf([
156
+ "voice",
157
+ "speech",
158
+ "speaks?",
159
+ "spoken",
160
+ "accent",
161
+ "lisp",
162
+ "stutter\\w*",
163
+ "stammer\\w*",
164
+ "whisper\\w*",
165
+ "tone",
166
+ "laugh\\w*",
167
+ "sings?",
168
+ "singing",
169
+ ]),
170
+ exemptMarks: false,
171
+ },
172
+ {
173
+ rule: "behaviour",
174
+ category: "behaviour, manner or temperament",
175
+ pattern: anyOf([
176
+ "moves?",
177
+ "moving",
178
+ "fidgets?",
179
+ "smil\\w+",
180
+ "grin\\w*",
181
+ "scowl\\w*",
182
+ "frown\\w*",
183
+ "gaze",
184
+ "stares?",
185
+ "glare\\w*",
186
+ "poise",
187
+ "bearing",
188
+ "demeanou?r",
189
+ "manner",
190
+ "habits?",
191
+ "grooming",
192
+ "expressive",
193
+ "intensity",
194
+ "shakes?",
195
+ "air of",
196
+ "carr(?:y|ies|ied|ying) (?:him|her|them|it)self",
197
+ "hyperactive",
198
+ "restless\\w*",
199
+ "jitter\\w*",
200
+ "twitch\\w*",
201
+ "nervous\\w*",
202
+ "anxious\\w*",
203
+ "energetic",
204
+ "lively",
205
+ "cheerful",
206
+ "sullen",
207
+ "shy",
208
+ "calm",
209
+ "placid",
210
+ "fidgety",
211
+ ]),
212
+ exemptMarks: true,
213
+ },
214
+ {
215
+ rule: "impression",
216
+ category: "an impression rather than a mark",
217
+ pattern: anyOf([
218
+ "beautiful",
219
+ "handsome",
220
+ "attractive",
221
+ "ugly",
222
+ "imposing",
223
+ "menacing",
224
+ "unnerv\\w+",
225
+ "disarming",
226
+ "commanding",
227
+ "presence",
228
+ "authority",
229
+ "pretty",
230
+ "lovely",
231
+ "gorgeous",
232
+ "stunning",
233
+ "comely",
234
+ "homely",
235
+ "hideous",
236
+ "striking\\w*",
237
+ "alluring",
238
+ "dashing",
239
+ "rugged",
240
+ "elegant\\w*",
241
+ "grace\\w*",
242
+ "feminine",
243
+ "masculine",
244
+ "girly",
245
+ "good looks",
246
+ ]),
247
+ exemptMarks: false,
248
+ },
249
+ {
250
+ rule: "handedness",
251
+ category: "handedness",
252
+ pattern: /\b(?:left|right)[- ]handed\b|\bambidextrous\b|\bhandedness\b|\bsouthpaw\b/iu,
253
+ exemptMarks: false,
254
+ },
255
+ {
256
+ rule: "sterility",
257
+ category: "sterility",
258
+ pattern: anyOf(["steril\\w*", "infertil\\w*", "barren", "childless"]),
259
+ exemptMarks: false,
260
+ },
261
+ {
262
+ rule: "age",
263
+ category: "an apparent-age reading",
264
+ pattern: anyOf([
265
+ "look(?:s|ing)? (?:younger|older|young|old)",
266
+ "younger than",
267
+ "older than",
268
+ "appears? (?:\\w+ )?(?:years|young|old)",
269
+ "apparent age",
270
+ "youthful",
271
+ "boyish",
272
+ "girlish",
273
+ "childlike",
274
+ "ageless",
275
+ "elderly",
276
+ ]),
277
+ exemptMarks: false,
278
+ },
279
+ ]);
280
+
281
+ /** A hyphen or an apostrophe standing between two letters, which an entry may carry. */
282
+ const JOINER = /(?<=\p{L})[-‐'’](?=\p{L})/gu;
283
+
284
+ /** Any punctuation or symbol character. */
285
+ const PUNCTUATION = /[\p{P}\p{S}]/gu;
286
+
287
+ /**
288
+ * The punctuation an entry carries, a hyphen or apostrophe between letters
289
+ * excepted.
290
+ *
291
+ * @param {string} entry - The entry as written.
292
+ * @returns {string[]} Each distinct character, in order of appearance.
293
+ */
294
+ export function featurePunctuation(entry) {
295
+ return [...new Set(entry.replace(JOINER, "").match(PUNCTUATION) ?? [])];
296
+ }
297
+
298
+ /** The colour and condition patterns, built once per vocabulary. */
299
+ const patterns = new WeakMap();
300
+
301
+ /**
302
+ * The colour and condition patterns of one being-look vocabulary.
303
+ *
304
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look
305
+ * @returns {{colour: RegExp, skin: RegExp, condition: RegExp, broad: RegExp, bareBroad: RegExp}}
306
+ */
307
+ function lookPatterns(look) {
308
+ let built = patterns.get(look);
309
+ if (built) return built;
310
+ const words = (field) => (look[field] ?? []).map(({ value }) => value);
311
+ const complexion = words("appearance.complexion");
312
+ const broad = BROAD_CONDITIONS.filter((word) => complexion.includes(word));
313
+ built = {
314
+ colour: anyOf(
315
+ [...new Set([...words("appearance.hair_color"), ...words("appearance.eye_color")])]
316
+ .map(phrase)
317
+ .concat(COLOUR_VARIANTS),
318
+ ),
319
+ skin: anyOf(words("appearance.skin_color").map(phrase)),
320
+ condition: anyOf([
321
+ ...complexion.filter((word) => !broad.includes(word)).map(phrase),
322
+ ...CONDITION_PHRASES,
323
+ ]),
324
+ broad: anyOf(broad.map(phrase)),
325
+ bareBroad: new RegExp(`^(?:${broad.map(phrase).join("|")})$`, "iu"),
326
+ };
327
+ patterns.set(look, built);
328
+ return built;
329
+ }
330
+
331
+ /**
332
+ * Why an `extra_features` entry is not a short plain description of one
333
+ * physical mark.
334
+ *
335
+ * @param {string} entry - The entry as written.
336
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
337
+ * being-look vocabulary.
338
+ * @returns {Array<{rule: string, message: string}>} One problem per rule the
339
+ * entry breaks; empty when it is a mark. `rule` is `hair-colour`,
340
+ * `eye-colour`, `skin-colour`, `complexion`, `punctuation`, or a rule of
341
+ * {@link NON_PHYSICAL_FEATURES}.
342
+ */
343
+ export function featureProblems(entry, look) {
344
+ const p = lookPatterns(look);
345
+ const has = (re) => re.test(entry);
346
+ const isMark = has(MARK);
347
+ const problems = [];
348
+ if (has(/\bhair\b/iu) && has(p.colour))
349
+ problems.push({
350
+ rule: "hair-colour",
351
+ message: "states a hair colour, which belongs in `appearance.hair_color`",
352
+ });
353
+ if (has(/\beyes?\b/iu) && has(p.colour))
354
+ problems.push({
355
+ rule: "eye-colour",
356
+ message: "states an eye colour, which belongs in `appearance.eye_color`",
357
+ });
358
+ if (has(SKIN) && has(p.skin))
359
+ problems.push({
360
+ rule: "skin-colour",
361
+ message: "states a skin colour, which belongs in `appearance.skin_color`",
362
+ });
363
+ if (
364
+ has(p.condition) ||
365
+ p.bareBroad.test(entry.trim()) ||
366
+ (has(p.broad) && has(SKIN) && !isMark)
367
+ )
368
+ problems.push({
369
+ rule: "complexion",
370
+ message: "states a skin condition, which belongs in `appearance.complexion`",
371
+ });
372
+ for (const { rule, category, pattern, exemptMarks } of NON_PHYSICAL_FEATURES) {
373
+ if (exemptMarks && isMark) continue;
374
+ if (has(pattern))
375
+ problems.push({ rule, message: `names ${category}, which is not a physical mark` });
376
+ }
377
+ const marks = featurePunctuation(entry);
378
+ if (marks.length)
379
+ problems.push({
380
+ rule: "punctuation",
381
+ message:
382
+ "carries punctuation " +
383
+ marks.map((mark) => `\`${mark}\``).join(" ") +
384
+ "; an extra feature is a short plain description of one visible mark, " +
385
+ "with no punctuation but a hyphen or apostrophe inside a word",
386
+ });
387
+ return problems;
388
+ }
389
+
390
+ /**
391
+ * The frontmatter lint's check of `data.appearance.extra_features`.
392
+ *
393
+ * @param {object} note - The note: `file`, `raw` and parsed `fm`.
394
+ * @param {Readonly<Record<string, readonly {value: string}[]>>} look - The
395
+ * being-look vocabulary.
396
+ * @returns {object[]} Error findings, each located at the entry at fault.
397
+ */
398
+ export function checkExtraFeatures(note, look) {
399
+ const appearance = note?.fm?.data?.appearance;
400
+ if (!isPlainMap(appearance)) return [];
401
+ const features = appearance.extra_features;
402
+ if (!Array.isArray(features)) return [];
403
+ const raw = note.raw ?? "";
404
+ const findings = [];
405
+ features.forEach((entry, index) => {
406
+ if (typeof entry !== "string") return;
407
+ const path = ["data", "appearance", "extra_features", index];
408
+ for (const { message } of featureProblems(entry, look)) {
409
+ findings.push({
410
+ file: note.file,
411
+ ...positionOfFrontmatterPath(raw, path),
412
+ severity: "error",
413
+ message: `\`data.appearance.extra_features[${index}]\` ${message}, but reads ${JSON.stringify(entry)}`,
414
+ });
415
+ }
416
+ });
417
+ return findings;
418
+ }
@@ -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 });