@heroiclands/package-build 23.3.0 → 23.3.1
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 +12 -0
- package/bin/content-commands.mjs +293 -2
- package/docs/commands.md +183 -0
- package/docs/content-format.md +2 -0
- package/docs/reference/format-details.md +211 -39
- package/docs/reference/note-types.md +97 -80
- package/engine/appearance-profile.mjs +330 -0
- package/engine/content-format.yaml +2 -0
- package/engine/data-keys.mjs +20 -4
- package/engine/language-names.mjs +634 -0
- package/engine/literature-notes.mjs +62 -27
- package/engine/note-vocabulary.mjs +161 -2
- package/engine/npc-generate.mjs +633 -0
- package/package.json +1 -1
- package/types/engine/appearance-profile.d.mts +94 -0
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/language-names.d.mts +140 -0
- package/types/engine/literature-notes.d.mts +14 -3
- package/types/engine/npc-generate.d.mts +124 -0
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
* A culture's appearance profile: what its people look like, as weighted words
|
|
16
|
+
* from the closed being-look vocabulary and height ranges by gender.
|
|
17
|
+
*
|
|
18
|
+
* A `lore` note of `subType: culture` states it as `data.appearanceProfile`,
|
|
19
|
+
* and `package-build npc new` draws a person from it. `npc new` refuses a
|
|
20
|
+
* culture that states none.
|
|
21
|
+
*
|
|
22
|
+
* - `frame`, `eye_color`, `hair_color`, `skin_color` and `complexion` each map
|
|
23
|
+
* a word of that field's vocabulary to a positive weight. A word's chance is
|
|
24
|
+
* its weight over the sum of the map's weights; a word left out is never
|
|
25
|
+
* drawn.
|
|
26
|
+
* - `height` maps a gender word to a `{min, max}` range, each a height in
|
|
27
|
+
* metres or feet and inches. `female` and `male` are required; a gender with
|
|
28
|
+
* no range of its own draws from the span of every range stated.
|
|
29
|
+
*
|
|
30
|
+
* Weight is not in the profile. It follows from the height drawn and the frame
|
|
31
|
+
* drawn, through {@link FRAME_MASS_INDEX}, so a heavy frame always weighs more
|
|
32
|
+
* than a light one at the same height.
|
|
33
|
+
*
|
|
34
|
+
* This module imports nothing from `note-vocabulary.mjs`, which declares the
|
|
35
|
+
* field and calls {@link checkAppearanceProfile}; the vocabulary is handed in.
|
|
36
|
+
* The two still meet in one import cycle through other modules, so what
|
|
37
|
+
* `note-vocabulary.mjs` reads here while it evaluates is a function, never a
|
|
38
|
+
* `const`.
|
|
39
|
+
*
|
|
40
|
+
* @module
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
44
|
+
import { fitsKind, isPlainMap } from "./data-keys.mjs";
|
|
45
|
+
import { parseBeingHeight } from "./being-measurements.mjs";
|
|
46
|
+
import { nearest } from "./near-miss.mjs";
|
|
47
|
+
|
|
48
|
+
/** The `lore` subType whose notes may carry a profile. */
|
|
49
|
+
export const PROFILE_SUBTYPE = "culture";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The genders every profile states a height range for.
|
|
53
|
+
*
|
|
54
|
+
* A function as well as a constant: `note-vocabulary.mjs` reads it while the
|
|
55
|
+
* module graph is still evaluating, before any `const` here is initialised,
|
|
56
|
+
* and a function declaration is bound from the start.
|
|
57
|
+
*
|
|
58
|
+
* @returns {readonly string[]} The genders.
|
|
59
|
+
*/
|
|
60
|
+
export function requiredHeightGenders() {
|
|
61
|
+
return Object.freeze(["female", "male"]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The genders every profile states a height range for. */
|
|
65
|
+
export const REQUIRED_HEIGHT_GENDERS = requiredHeightGenders();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The body-mass index band, in kg/m², each frame word means, lightest to
|
|
69
|
+
* heaviest. The bands meet end to end, so every weight at a given height falls
|
|
70
|
+
* in exactly one frame.
|
|
71
|
+
*
|
|
72
|
+
* @type {Readonly<Record<string, readonly [number, number]>>}
|
|
73
|
+
*/
|
|
74
|
+
export const FRAME_MASS_INDEX = Object.freeze({
|
|
75
|
+
scant: Object.freeze([16.5, 19]),
|
|
76
|
+
light: Object.freeze([19, 21.5]),
|
|
77
|
+
medium: Object.freeze([21.5, 24.5]),
|
|
78
|
+
heavy: Object.freeze([24.5, 28]),
|
|
79
|
+
massive: Object.freeze([28, 33]),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One thing wrong with a profile.
|
|
84
|
+
*
|
|
85
|
+
* @typedef {object} ProfileProblem
|
|
86
|
+
* @property {Array<string|number>} path - Where, from the profile itself.
|
|
87
|
+
* @property {boolean} [key] - The fault is the key at `path`, not its value.
|
|
88
|
+
* @property {boolean} [shape] - The frontmatter lint's inner-key walk reports
|
|
89
|
+
* this too, from the field's declaration.
|
|
90
|
+
* @property {string} message - What is wrong, naming the full `data.` path.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/** `data.appearanceProfile.<path>`, as a reader writes it. */
|
|
94
|
+
function label(path) {
|
|
95
|
+
return ["data.appearanceProfile", ...path].join(".");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The words of one vocabulary field, by the profile key that names it. */
|
|
99
|
+
function wordsFor(lookVocabulary, key) {
|
|
100
|
+
const name = Object.keys(lookVocabulary).find((field) => field.split(".").at(-1) === key);
|
|
101
|
+
return name ? lookVocabulary[name].map(({ value }) => value) : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The profile keys that take weighted words, read from the vocabulary: every
|
|
106
|
+
* being-look field but `gender`, by its last segment.
|
|
107
|
+
*
|
|
108
|
+
* @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary
|
|
109
|
+
* @returns {string[]} The keys, in the vocabulary's order.
|
|
110
|
+
*/
|
|
111
|
+
export function profileLookKeys(lookVocabulary) {
|
|
112
|
+
return Object.keys(lookVocabulary)
|
|
113
|
+
.filter((field) => field !== "gender")
|
|
114
|
+
.map((field) => /** @type {string} */ (field.split(".").at(-1)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Everything wrong with a profile.
|
|
119
|
+
*
|
|
120
|
+
* @param {unknown} profile - The profile as authored.
|
|
121
|
+
* @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
|
|
122
|
+
* The being-look vocabulary.
|
|
123
|
+
* @returns {ProfileProblem[]} The problems, in the order a reader meets them.
|
|
124
|
+
*/
|
|
125
|
+
export function profileProblems(profile, lookVocabulary) {
|
|
126
|
+
const problems = [];
|
|
127
|
+
const shape = (path, message, key = false) =>
|
|
128
|
+
problems.push({ path, message, shape: true, ...(key ? { key } : {}) });
|
|
129
|
+
const meaning = (path, message, key = false) =>
|
|
130
|
+
problems.push({ path, message, ...(key ? { key } : {}) });
|
|
131
|
+
|
|
132
|
+
if (!isPlainMap(profile)) {
|
|
133
|
+
shape([], `\`${label([])}\` should be a map, but reads ${JSON.stringify(profile)}`);
|
|
134
|
+
return problems;
|
|
135
|
+
}
|
|
136
|
+
const lookKeys = profileLookKeys(lookVocabulary);
|
|
137
|
+
const known = [...lookKeys, "height"];
|
|
138
|
+
for (const key of Object.keys(profile)) {
|
|
139
|
+
if (known.includes(key)) continue;
|
|
140
|
+
const guess = nearest(key, known);
|
|
141
|
+
shape(
|
|
142
|
+
[key],
|
|
143
|
+
`"${key}" is not a key of \`data.appearanceProfile\`` +
|
|
144
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
145
|
+
true,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const key of lookKeys) {
|
|
150
|
+
const weights = profile[key];
|
|
151
|
+
if (weights === undefined || weights === null) {
|
|
152
|
+
shape([], `\`data.appearanceProfile\` must state \`${key}\``, true);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!isPlainMap(weights)) {
|
|
156
|
+
shape([key], `\`${label([key])}\` should be a map of words to weights`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const words = wordsFor(lookVocabulary, key);
|
|
160
|
+
const entries = Object.entries(weights);
|
|
161
|
+
if (!entries.length) {
|
|
162
|
+
meaning(
|
|
163
|
+
[key],
|
|
164
|
+
`\`${label([key])}\` must weight at least one word: ${words.join(", ")}`,
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const [word, weight] of entries) {
|
|
170
|
+
if (!words.includes(word)) {
|
|
171
|
+
const guess = nearest(word, words);
|
|
172
|
+
meaning(
|
|
173
|
+
[key, word],
|
|
174
|
+
`\`${label([key])}\` names ${JSON.stringify(word)}, which is not a ${key} ` +
|
|
175
|
+
`word; it takes ${words.join(", ")}` +
|
|
176
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
177
|
+
true,
|
|
178
|
+
);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (weight === null || !fitsKind(weight, "number")) {
|
|
182
|
+
shape(
|
|
183
|
+
[key, word],
|
|
184
|
+
`\`${label([key, word])}\` should be a positive weight, but reads ` +
|
|
185
|
+
JSON.stringify(weight),
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!(Number(weight) > 0))
|
|
190
|
+
meaning(
|
|
191
|
+
[key, word],
|
|
192
|
+
`\`${label([key, word])}\` should be a positive weight, but reads ` +
|
|
193
|
+
`${JSON.stringify(weight)}; leave a word out to never draw it`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const heights = profile.height;
|
|
199
|
+
if (heights === undefined || heights === null) {
|
|
200
|
+
shape([], "`data.appearanceProfile` must state `height`", true);
|
|
201
|
+
return problems;
|
|
202
|
+
}
|
|
203
|
+
if (!isPlainMap(heights)) {
|
|
204
|
+
shape(["height"], `\`${label(["height"])}\` should be a map of genders to ranges`);
|
|
205
|
+
return problems;
|
|
206
|
+
}
|
|
207
|
+
const genders = wordsFor(lookVocabulary, "gender");
|
|
208
|
+
for (const gender of REQUIRED_HEIGHT_GENDERS) {
|
|
209
|
+
if (heights[gender] === undefined || heights[gender] === null)
|
|
210
|
+
meaning(
|
|
211
|
+
["height"],
|
|
212
|
+
`\`${label(["height"])}\` must state a range for \`${gender}\``,
|
|
213
|
+
true,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
for (const [gender, range] of Object.entries(heights)) {
|
|
217
|
+
if (!genders.includes(gender)) {
|
|
218
|
+
const guess = nearest(gender, genders);
|
|
219
|
+
meaning(
|
|
220
|
+
["height", gender],
|
|
221
|
+
`${JSON.stringify(gender)} is not a gender; \`${label(["height"])}\` is keyed by ` +
|
|
222
|
+
`${genders.join(", ")}` +
|
|
223
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
224
|
+
true,
|
|
225
|
+
);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (range === null || range === undefined) continue;
|
|
229
|
+
if (!isPlainMap(range)) {
|
|
230
|
+
shape(
|
|
231
|
+
["height", gender],
|
|
232
|
+
`\`${label(["height", gender])}\` should be a {min, max} range`,
|
|
233
|
+
);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const bounds = {};
|
|
237
|
+
for (const end of ["min", "max"]) {
|
|
238
|
+
const value = range[end];
|
|
239
|
+
if (value === undefined || value === null || !fitsKind(value, "string")) {
|
|
240
|
+
shape(
|
|
241
|
+
value === undefined || value === null ?
|
|
242
|
+
["height", gender]
|
|
243
|
+
: ["height", gender, end],
|
|
244
|
+
`\`${label(["height", gender])}\` must state \`${end}\` as a height`,
|
|
245
|
+
value === undefined || value === null,
|
|
246
|
+
);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const inches = parseBeingHeight(value);
|
|
250
|
+
if (inches === null) {
|
|
251
|
+
meaning(
|
|
252
|
+
["height", gender, end],
|
|
253
|
+
`\`${label(["height", gender, end])}\` needs metres or feet and inches, ` +
|
|
254
|
+
`such as 1.75m or 5' 9", but reads ${JSON.stringify(value)}`,
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
bounds[end] = inches;
|
|
259
|
+
}
|
|
260
|
+
if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max)
|
|
261
|
+
meaning(
|
|
262
|
+
["height", gender],
|
|
263
|
+
`\`${label(["height", gender])}\` has its \`min\` above its \`max\``,
|
|
264
|
+
true,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return problems;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The frontmatter lint's check of `data.appearanceProfile`.
|
|
272
|
+
*
|
|
273
|
+
* Scoped to a `lore` note of `subType: culture`: on any other lore it is an
|
|
274
|
+
* error at the key. On a culture it reports what the field's declared inner
|
|
275
|
+
* keys cannot: words outside the vocabulary, weights that are not positive, a
|
|
276
|
+
* word map with no words, height ranges that are unreadable, reversed or keyed
|
|
277
|
+
* by no gender, and a missing `female` or `male` range.
|
|
278
|
+
*
|
|
279
|
+
* @param {object} note - The note: `file`, `raw` and parsed `fm`.
|
|
280
|
+
* @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
|
|
281
|
+
* The being-look vocabulary.
|
|
282
|
+
* @returns {object[]} Findings, located at the word, weight or key at fault.
|
|
283
|
+
*/
|
|
284
|
+
export function checkAppearanceProfile(note, lookVocabulary) {
|
|
285
|
+
const data = note?.fm?.data;
|
|
286
|
+
if (!isPlainMap(data)) return [];
|
|
287
|
+
const profile = data.appearanceProfile;
|
|
288
|
+
if (profile === undefined || profile === null) return [];
|
|
289
|
+
const raw = note.raw ?? "";
|
|
290
|
+
const finding = (path, message, key) => ({
|
|
291
|
+
file: note.file,
|
|
292
|
+
...positionOfFrontmatterPath(raw, ["data", "appearanceProfile", ...path], { key }),
|
|
293
|
+
severity: "error",
|
|
294
|
+
message,
|
|
295
|
+
});
|
|
296
|
+
const subType = String(note.fm?.subType ?? "").toLowerCase();
|
|
297
|
+
if (subType !== PROFILE_SUBTYPE)
|
|
298
|
+
return [
|
|
299
|
+
finding(
|
|
300
|
+
[],
|
|
301
|
+
"`data.appearanceProfile` describes a culture's people, and this note's " +
|
|
302
|
+
`\`subType\` is not \`${PROFILE_SUBTYPE}\``,
|
|
303
|
+
true,
|
|
304
|
+
),
|
|
305
|
+
];
|
|
306
|
+
return profileProblems(profile, lookVocabulary)
|
|
307
|
+
.filter((problem) => !problem.shape)
|
|
308
|
+
.map((problem) => finding(problem.path, problem.message, Boolean(problem.key)));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A profile's problems as findings at the culture note that states it, for a
|
|
313
|
+
* caller holding the note but not running the lint.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} note - The culture note: `file`, `raw` and parsed `fm`.
|
|
316
|
+
* @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
|
|
317
|
+
* The being-look vocabulary.
|
|
318
|
+
* @returns {object[]} Every problem, shape included, as findings.
|
|
319
|
+
*/
|
|
320
|
+
export function profileFindings(note, lookVocabulary) {
|
|
321
|
+
const raw = note?.raw ?? "";
|
|
322
|
+
return profileProblems(note?.fm?.data?.appearanceProfile, lookVocabulary).map((problem) => ({
|
|
323
|
+
...(note?.file ? { file: note.file } : {}),
|
|
324
|
+
...positionOfFrontmatterPath(raw, ["data", "appearanceProfile", ...problem.path], {
|
|
325
|
+
key: Boolean(problem.key),
|
|
326
|
+
}),
|
|
327
|
+
severity: "error",
|
|
328
|
+
message: problem.message,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
@@ -202,6 +202,7 @@ types:
|
|
|
202
202
|
- templatePriority
|
|
203
203
|
- masteryLevel
|
|
204
204
|
- parentSkill
|
|
205
|
+
- names
|
|
205
206
|
subTypes:
|
|
206
207
|
- social
|
|
207
208
|
- nature
|
|
@@ -258,6 +259,7 @@ types:
|
|
|
258
259
|
- subjects
|
|
259
260
|
- language
|
|
260
261
|
- events
|
|
262
|
+
- appearanceProfile
|
|
261
263
|
subTypes:
|
|
262
264
|
- cosmology
|
|
263
265
|
- deity
|
package/engine/data-keys.mjs
CHANGED
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
* Shortcode, a pack, a name), and this describes the value under each key.
|
|
34
34
|
* The keys themselves are checked as the field's `keyKind` says.
|
|
35
35
|
*
|
|
36
|
+
* A map declaring both `fields` and `values` holds its declared keys, each
|
|
37
|
+
* checked by its own declaration, beside keys named by the author, each
|
|
38
|
+
* checked by `values`. A language's `data.names` is one: `pattern` and
|
|
39
|
+
* `given` beside the lists the language names for itself.
|
|
40
|
+
*
|
|
36
41
|
* Dotted field names (`appearance.eye_color`) declare a container's keys by
|
|
37
42
|
* their segments, and the container is closed to them just the same.
|
|
38
43
|
*
|
|
@@ -57,7 +62,7 @@ import { nearest } from "./near-miss.mjs";
|
|
|
57
62
|
* @property {string} name - The key as an author writes it.
|
|
58
63
|
* @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
|
|
59
64
|
* `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
|
|
60
|
-
* or `string-or-
|
|
65
|
+
* `string-or-list`, or `string-list-or-map` — see {@link fitsKind}. Absent, the check
|
|
61
66
|
* makes no claim about the value.
|
|
62
67
|
* @property {readonly string[]} [allowed] - The closed set a present value is
|
|
63
68
|
* one of, where the key takes a fixed word.
|
|
@@ -145,6 +150,10 @@ export function fitsKind(value, kind) {
|
|
|
145
150
|
return isScalar(value) || fitsKind(value, "map");
|
|
146
151
|
case "string-or-list":
|
|
147
152
|
return isScalar(value) || Array.isArray(value);
|
|
153
|
+
// One value, a list of them described by `entries`, or a map of them
|
|
154
|
+
// described by `values`.
|
|
155
|
+
case "string-list-or-map":
|
|
156
|
+
return fitsKind(value, "string-or-list") || fitsKind(value, "map");
|
|
148
157
|
default:
|
|
149
158
|
return true;
|
|
150
159
|
}
|
|
@@ -258,7 +267,7 @@ export function checkDataKeys(note, fields) {
|
|
|
258
267
|
*/
|
|
259
268
|
const walk = (spec, value, path) => {
|
|
260
269
|
if (spec.fields && isPlainMap(value)) {
|
|
261
|
-
closeMap(spec.fields, value, path);
|
|
270
|
+
closeMap(spec.fields, value, path, spec.values);
|
|
262
271
|
return;
|
|
263
272
|
}
|
|
264
273
|
if (Array.isArray(value)) {
|
|
@@ -308,11 +317,18 @@ export function checkDataKeys(note, fields) {
|
|
|
308
317
|
return false;
|
|
309
318
|
};
|
|
310
319
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
320
|
+
/**
|
|
321
|
+
* Close one map to its declared keys, and walk each that is present. With
|
|
322
|
+
* `rest`, a key it does not declare is walked against `rest` instead.
|
|
323
|
+
*/
|
|
324
|
+
const closeMap = (inner, value, path, rest) => {
|
|
313
325
|
const names = inner.map((spec) => spec.name);
|
|
314
326
|
for (const key of Object.keys(value)) {
|
|
315
327
|
if (names.includes(key)) continue;
|
|
328
|
+
if (rest) {
|
|
329
|
+
visit(rest, value[key], [...path, key]);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
316
332
|
const guess = nearest(key, names);
|
|
317
333
|
findings.push(
|
|
318
334
|
finding(
|