@heroiclands/package-build 23.2.2 → 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.
@@ -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
@@ -703,6 +705,12 @@ vocabularies:
703
705
  - "4"
704
706
  - "5"
705
707
  - "6"
708
+ holdingModel:
709
+ - held
710
+ - endowed
711
+ - registered
712
+ - kin
713
+ - polis
706
714
  bearing:
707
715
  - N
708
716
  - NE
@@ -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-list` — see {@link fitsKind}. Absent, the check
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)) {
@@ -289,9 +298,13 @@ export function checkDataKeys(note, fields) {
289
298
  walk(spec, value, path);
290
299
  };
291
300
 
292
- /** Whether a present value is in the spec's closed set, if it has one; else a finding. */
301
+ /**
302
+ * Whether a present value is in the spec's closed set, if it has one; else a
303
+ * finding. A list is not itself a word: its entries are checked against
304
+ * their own declaration as the walk reaches them.
305
+ */
293
306
  const inAllowed = (spec, value, path) => {
294
- if (!spec.allowed || spec.allowed.includes(value)) return true;
307
+ if (!spec.allowed || Array.isArray(value) || spec.allowed.includes(value)) return true;
295
308
  const guess = typeof value === "string" ? nearest(value, spec.allowed) : undefined;
296
309
  findings.push(
297
310
  finding(
@@ -304,11 +317,18 @@ export function checkDataKeys(note, fields) {
304
317
  return false;
305
318
  };
306
319
 
307
- /** Close one map to its declared keys, and walk each that is present. */
308
- const closeMap = (inner, value, path) => {
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) => {
309
325
  const names = inner.map((spec) => spec.name);
310
326
  for (const key of Object.keys(value)) {
311
327
  if (names.includes(key)) continue;
328
+ if (rest) {
329
+ visit(rest, value[key], [...path, key]);
330
+ continue;
331
+ }
312
332
  const guess = nearest(key, names);
313
333
  findings.push(
314
334
  finding(
package/engine/index.mjs CHANGED
@@ -103,6 +103,9 @@ export * as populations from "./populations.mjs";
103
103
  /** What trade a settlement supports: the six-step market scale, and the check that holds a value to it. */
104
104
  export * as marketClass from "./market-class.mjs";
105
105
 
106
+ /** A region's settlements table: the holding models, the columns, and the check that holds a table to them. */
107
+ export * as settlementTables from "./settlement-tables.mjs";
108
+
106
109
  /** The places a map is drawn from, read from the content index, and what `parents` says about them. */
107
110
  export * as mapPlaces from "./map-places.mjs";
108
111
 
@@ -491,7 +491,7 @@ export function humanizeFieldName(name) {
491
491
  }
492
492
 
493
493
  /**
494
- * An enumerated value as a reader sees it: `graying_brown` → `graying brown`.
494
+ * An enumerated value as a reader sees it: `greying_brown` → `greying brown`.
495
495
  *
496
496
  * @param {unknown} value - The authored value.
497
497
  * @returns {string} The text.