@heroiclands/package-build 23.3.0 → 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 +18 -0
- package/bin/content-commands.mjs +308 -2
- package/docs/commands.md +191 -0
- package/docs/content-format.md +9 -0
- package/docs/reference/format-details.md +321 -44
- package/docs/reference/note-types.md +98 -80
- package/engine/appearance-profile.mjs +330 -0
- package/engine/content-format.yaml +3 -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 +20 -4
- package/engine/frontmatter-lint.mjs +15 -1
- package/engine/language-names.mjs +634 -0
- package/engine/literature-notes.mjs +62 -27
- package/engine/metadata-index.mjs +11 -0
- package/engine/note-vocabulary.mjs +171 -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/culture-parents.d.mts +29 -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,633 @@
|
|
|
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
|
+
* The engine behind `package-build npc new`: a spec in, a complete being note
|
|
16
|
+
* out.
|
|
17
|
+
*
|
|
18
|
+
* A spec is a possibly incomplete set of frontmatter fields. `data.culture`
|
|
19
|
+
* and `data.gender` are required; every other field the being needs is
|
|
20
|
+
* generated where the spec leaves it out, and a field the spec gives is
|
|
21
|
+
* written exactly as given, at every depth. Two top-level keys are not
|
|
22
|
+
* frontmatter: `appearance` and `dossier` are the markdown bodies of the
|
|
23
|
+
* note's two sections, written verbatim.
|
|
24
|
+
*
|
|
25
|
+
* **Generation is seeded from the spec.** The seed is the spec's canonical
|
|
26
|
+
* serialisation — JSON with every map's keys sorted — joined to the optional
|
|
27
|
+
* `seed` the caller hands in. Each field draws from its own stream, seeded by
|
|
28
|
+
* that and the field's path, so the same spec and seed always give the same
|
|
29
|
+
* note, whether or not the spec states a name.
|
|
30
|
+
*
|
|
31
|
+
* What is generated, and from where:
|
|
32
|
+
*
|
|
33
|
+
* - `name.full` — drawn from the culture's language's name lists by their
|
|
34
|
+
* pattern, the given name from the list for `data.gender` (see
|
|
35
|
+
* `language-names.mjs`). A name the spec states is kept, and must fit those
|
|
36
|
+
* lists: every placeholder filled by a name of its list, the given name from
|
|
37
|
+
* the gender's.
|
|
38
|
+
* - `shortcode` — the name transliterated to lowercase ASCII letters and
|
|
39
|
+
* digits, cut to twelve characters, with a counter replacing its tail where a
|
|
40
|
+
* being in the package already holds it.
|
|
41
|
+
* - `type: being`, `subType: npc`, `tags: [generated, draft]`.
|
|
42
|
+
* - `frame`, and `appearance.eye_color`, `hair_color`, `skin_color` and
|
|
43
|
+
* `complexion` — weighted draws from the culture's appearance profile.
|
|
44
|
+
* - `height` — from the profile's range for the gender, in feet and inches.
|
|
45
|
+
* - `weight` — from the height and the frame's body-mass band, in pounds.
|
|
46
|
+
* - `born` — an adult age of {@link ADULT_AGE} years before the package's
|
|
47
|
+
* present, as a neutral `<year>.<day>` date. A package that states no present
|
|
48
|
+
* gets `born: unknown`.
|
|
49
|
+
* - `archetypes: [commoner]`, the archetype for a person who fits no more
|
|
50
|
+
* specific one.
|
|
51
|
+
* - `icon`, `species` and `packFolder` — what the caller derives from the
|
|
52
|
+
* package's other beings; left out where it derives nothing.
|
|
53
|
+
* - `templatePriority`, `occupation` and `died` are `null`; `lore` and `homes`
|
|
54
|
+
* are `[]`; `affiliations` is `{}`; `appearance.extra_features` is `[]`.
|
|
55
|
+
*
|
|
56
|
+
* Plain ESM with no filesystem access; the CLI resolves the culture, its
|
|
57
|
+
* language's name lists and the present and hands them in.
|
|
58
|
+
*
|
|
59
|
+
* @module
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import unidecode from "unidecode";
|
|
63
|
+
import YAML from "yaml";
|
|
64
|
+
import { BEING_LOOK_VOCABULARY } from "./note-vocabulary.mjs";
|
|
65
|
+
import { FRAME_MASS_INDEX, profileLookKeys } from "./appearance-profile.mjs";
|
|
66
|
+
import { parseBeingHeight } from "./being-measurements.mjs";
|
|
67
|
+
import { computeAge, parseAgeMagnitude } from "./being-age.mjs";
|
|
68
|
+
import { parseNoteDate } from "./note-dates.mjs";
|
|
69
|
+
import { eraYear } from "./calendars.mjs";
|
|
70
|
+
import { parseAddress, renderAddress } from "./address.mjs";
|
|
71
|
+
import { isPlainMap } from "./data-keys.mjs";
|
|
72
|
+
import { drawName, nameMisfit } from "./language-names.mjs";
|
|
73
|
+
|
|
74
|
+
/** The youngest and oldest age, in whole years, a generated person is born to. */
|
|
75
|
+
export const ADULT_AGE = Object.freeze({ min: 18, max: 60 });
|
|
76
|
+
|
|
77
|
+
/** The longest shortcode the derivation makes. */
|
|
78
|
+
export const SHORTCODE_LENGTH = 12;
|
|
79
|
+
|
|
80
|
+
/** The spec fields `npc new` requires, as paths. */
|
|
81
|
+
export const REQUIRED_SPEC_FIELDS = Object.freeze(["data.culture", "data.gender"]);
|
|
82
|
+
|
|
83
|
+
/** The top-level spec keys that are section bodies rather than frontmatter. */
|
|
84
|
+
const PROSE_KEYS = Object.freeze(["appearance", "dossier"]);
|
|
85
|
+
|
|
86
|
+
const INCHES_PER_METRE = 39.3701;
|
|
87
|
+
const POUNDS_PER_KILOGRAM = 2.20462;
|
|
88
|
+
|
|
89
|
+
/** A 32-bit hash of a string (cyrb53's low half), the seed of one stream. */
|
|
90
|
+
function hash(text) {
|
|
91
|
+
let h1 = 0xdeadbeef;
|
|
92
|
+
let h2 = 0x41c6ce57;
|
|
93
|
+
for (let i = 0; i < text.length; i++) {
|
|
94
|
+
const ch = text.charCodeAt(i);
|
|
95
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
96
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
97
|
+
}
|
|
98
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
99
|
+
return h1 >>> 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A deterministic stream of numbers in `[0, 1)` (mulberry32), seeded by the
|
|
104
|
+
* generation's seed and the path of the field it draws.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} seed - The canonical spec and the caller's seed.
|
|
107
|
+
* @param {string} field - The field's path.
|
|
108
|
+
* @returns {() => number} The stream.
|
|
109
|
+
*/
|
|
110
|
+
function stream(seed, field) {
|
|
111
|
+
let state = hash(`${seed}\u0000${field}`);
|
|
112
|
+
return () => {
|
|
113
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
114
|
+
let t = state;
|
|
115
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
116
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
117
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One word drawn from a map of words to weights, in the map's own order. */
|
|
122
|
+
function weighted(weights, next) {
|
|
123
|
+
const entries = Object.entries(weights).filter(([, w]) => Number(w) > 0);
|
|
124
|
+
const total = entries.reduce((sum, [, w]) => sum + Number(w), 0);
|
|
125
|
+
let point = next() * total;
|
|
126
|
+
for (const [word, w] of entries) {
|
|
127
|
+
point -= Number(w);
|
|
128
|
+
if (point < 0) return word;
|
|
129
|
+
}
|
|
130
|
+
return entries.at(-1)?.[0];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A value as JSON with every map's keys sorted, so two specs stating the same
|
|
135
|
+
* fields in a different order serialise alike.
|
|
136
|
+
*
|
|
137
|
+
* @param {unknown} value - A spec, or any value in one.
|
|
138
|
+
* @returns {string} The canonical serialisation.
|
|
139
|
+
*/
|
|
140
|
+
export function canonicalSpec(value) {
|
|
141
|
+
if (Array.isArray(value)) return `[${value.map(canonicalSpec).join(",")}]`;
|
|
142
|
+
if (value !== null && typeof value === "object")
|
|
143
|
+
return `{${Object.keys(value)
|
|
144
|
+
.sort()
|
|
145
|
+
.filter((key) => value[key] !== undefined)
|
|
146
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalSpec(value[key])}`)
|
|
147
|
+
.join(",")}}`;
|
|
148
|
+
return JSON.stringify(value) ?? "null";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether a spec value counts as given — any value but `undefined`. */
|
|
152
|
+
const given = (value) => value !== undefined;
|
|
153
|
+
|
|
154
|
+
/** The value at a dotted path, or `undefined`. */
|
|
155
|
+
function at(object, path) {
|
|
156
|
+
let value = object;
|
|
157
|
+
for (const segment of path.split(".")) {
|
|
158
|
+
value = isPlainMap(value) ? value[segment] : undefined;
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Why a spec cannot be generated from, or nothing.
|
|
165
|
+
*
|
|
166
|
+
* @param {unknown} spec - The spec as read.
|
|
167
|
+
* @returns {string[]} One sentence per problem.
|
|
168
|
+
*/
|
|
169
|
+
export function npcSpecProblems(spec) {
|
|
170
|
+
if (!isPlainMap(spec)) return ["the spec must be a map of frontmatter fields"];
|
|
171
|
+
const problems = [];
|
|
172
|
+
for (const field of REQUIRED_SPEC_FIELDS) {
|
|
173
|
+
const value = at(spec, field);
|
|
174
|
+
if (typeof value !== "string" || !value.trim())
|
|
175
|
+
problems.push(`the spec must state \`${field}\``);
|
|
176
|
+
}
|
|
177
|
+
if (given(spec.name) && !isPlainMap(spec.name))
|
|
178
|
+
problems.push("`name` must be a map, such as `{full: ...}`");
|
|
179
|
+
else if (
|
|
180
|
+
given(spec.name?.full) &&
|
|
181
|
+
(typeof spec.name.full !== "string" || !spec.name.full.trim())
|
|
182
|
+
)
|
|
183
|
+
problems.push("the spec's `name.full`, when stated, must be a name");
|
|
184
|
+
if (given(spec.type) && spec.type !== "being")
|
|
185
|
+
problems.push(`the spec states \`type: ${spec.type}\`; \`npc new\` writes a being`);
|
|
186
|
+
const genders = BEING_LOOK_VOCABULARY.gender.map(({ value }) => value);
|
|
187
|
+
const gender = at(spec, "data.gender");
|
|
188
|
+
if (typeof gender === "string" && gender.trim() && !genders.includes(gender))
|
|
189
|
+
problems.push(
|
|
190
|
+
`\`data.gender\` is ${JSON.stringify(gender)}; it takes ${genders.join(", ")}`,
|
|
191
|
+
);
|
|
192
|
+
for (const key of PROSE_KEYS) {
|
|
193
|
+
if (given(spec[key]) && typeof spec[key] !== "string")
|
|
194
|
+
problems.push(`\`${key}\` is the ${key} section's markdown, so it must be a string`);
|
|
195
|
+
}
|
|
196
|
+
return problems;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The shortcode a name derives, free of every shortcode in `taken`.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} name - `name.full`.
|
|
203
|
+
* @param {ReadonlySet<string>} taken - Shortcodes the package's beings hold.
|
|
204
|
+
* @returns {string} The shortcode.
|
|
205
|
+
*/
|
|
206
|
+
export function npcShortcode(name, taken) {
|
|
207
|
+
const base =
|
|
208
|
+
unidecode(name)
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
.replace(/[^a-z0-9]/g, "")
|
|
211
|
+
.slice(0, SHORTCODE_LENGTH) || "npc";
|
|
212
|
+
if (!taken.has(base)) return base;
|
|
213
|
+
for (let n = 2; ; n++) {
|
|
214
|
+
const candidate = base.slice(0, SHORTCODE_LENGTH - String(n).length) + n;
|
|
215
|
+
if (!taken.has(candidate)) return candidate;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A whole number of inches as `6' 4"`. */
|
|
220
|
+
function frontmatterHeight(inches) {
|
|
221
|
+
return `${Math.floor(inches / 12)}' ${inches % 12}"`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A height value as prose: `6'4"`. */
|
|
225
|
+
function proseHeight(value) {
|
|
226
|
+
const inches = parseBeingHeight(value);
|
|
227
|
+
if (inches === null) return String(value);
|
|
228
|
+
return `${Math.floor(inches / 12)}'${inches % 12}"`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The height range a gender draws from: its own, or the span of every range
|
|
233
|
+
* the profile states.
|
|
234
|
+
*/
|
|
235
|
+
function heightRange(profile, gender) {
|
|
236
|
+
const ranges =
|
|
237
|
+
isPlainMap(profile.height?.[gender]) ?
|
|
238
|
+
[profile.height[gender]]
|
|
239
|
+
: Object.values(profile.height ?? {}).filter(isPlainMap);
|
|
240
|
+
const mins = ranges.map((r) => parseBeingHeight(r.min)).filter((v) => v !== null);
|
|
241
|
+
const maxes = ranges.map((r) => parseBeingHeight(r.max)).filter((v) => v !== null);
|
|
242
|
+
return { min: Math.min(...mins), max: Math.max(...maxes) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A height in whole inches, peaked towards the middle of its range. */
|
|
246
|
+
function drawHeight(range, next) {
|
|
247
|
+
const middle = (next() + next()) / 2;
|
|
248
|
+
return Math.round(range.min + (range.max - range.min) * middle);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A weight in whole pounds, inside the frame's body-mass band at this height. */
|
|
252
|
+
function drawWeight(inches, frame, next) {
|
|
253
|
+
const [low, high] = FRAME_MASS_INDEX[frame] ?? FRAME_MASS_INDEX.medium;
|
|
254
|
+
// Kept off the band's edges, so rounding to whole pounds stays inside it.
|
|
255
|
+
const index = low + 0.1 + (high - low - 0.2) * next();
|
|
256
|
+
const metres = inches / INCHES_PER_METRE;
|
|
257
|
+
return Math.round(index * metres * metres * POUNDS_PER_KILOGRAM);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The frame whose body-mass band holds a height and weight, if both read. */
|
|
261
|
+
function frameOf(height, weight) {
|
|
262
|
+
const inches = parseBeingHeight(height);
|
|
263
|
+
const match = /^(\d+) ?lbs$/.exec(String(weight ?? ""));
|
|
264
|
+
const metric = /^(\d+(?:\.\d+)?) ?kg$/.exec(String(weight ?? ""));
|
|
265
|
+
const kilograms =
|
|
266
|
+
match ? Number(match[1]) / POUNDS_PER_KILOGRAM
|
|
267
|
+
: metric ? Number(metric[1])
|
|
268
|
+
: typeof weight === "number" ? weight
|
|
269
|
+
: null;
|
|
270
|
+
if (inches === null || kilograms === null) return undefined;
|
|
271
|
+
const metres = inches / INCHES_PER_METRE;
|
|
272
|
+
const index = kilograms / (metres * metres);
|
|
273
|
+
const frames = Object.keys(FRAME_MASS_INDEX);
|
|
274
|
+
return (
|
|
275
|
+
frames.find((f) => index >= FRAME_MASS_INDEX[f][0] && index < FRAME_MASS_INDEX[f][1]) ??
|
|
276
|
+
(index < FRAME_MASS_INDEX[frames[0]][0] ? frames[0] : frames.at(-1))
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A neutral birth date `age` whole years before the present, or `unknown` where
|
|
282
|
+
* the present does not read.
|
|
283
|
+
*/
|
|
284
|
+
function drawBorn(age, present, dateContext, next) {
|
|
285
|
+
if (present === undefined || present === null) return "unknown";
|
|
286
|
+
const parsed = parseNoteDate(present, { ...dateContext, allowUnknown: false }).date;
|
|
287
|
+
if (!parsed?.known || parsed.canonicalYear == null) return "unknown";
|
|
288
|
+
const days = dateContext?.daysPerYear;
|
|
289
|
+
const day = Number.isSafeInteger(days) && days > 0 ? 1 + Math.floor(next() * days) : null;
|
|
290
|
+
const year = eraYear(parsed.canonicalYear - age);
|
|
291
|
+
const text = (y) => (day === null ? String(y) : `${y}.${day}`);
|
|
292
|
+
for (const candidate of [year, year - 1, year + 1]) {
|
|
293
|
+
if (computeAge(text(candidate), present, dateContext) === age) return text(candidate);
|
|
294
|
+
}
|
|
295
|
+
return text(year);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A vocabulary word as running prose reads it: its label, lowercased. */
|
|
299
|
+
function wordLabel(field, value) {
|
|
300
|
+
const word = BEING_LOOK_VOCABULARY[field]?.find((w) => w.value === value);
|
|
301
|
+
return (word?.label ?? String(value).replaceAll("_", " ")).toLowerCase();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** `a`, `a and b`, or `a, b, and c`. */
|
|
305
|
+
function series(items) {
|
|
306
|
+
if (items.length < 3) return items.join(" and ");
|
|
307
|
+
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The noun and pronouns a gender is written with. */
|
|
311
|
+
function person(gender) {
|
|
312
|
+
if (gender === "male") return { noun: "man", subject: "He", has: "has", possessive: "His" };
|
|
313
|
+
if (gender === "female")
|
|
314
|
+
return { noun: "woman", subject: "She", has: "has", possessive: "Her" };
|
|
315
|
+
return { noun: "person", subject: "They", has: "have", possessive: "Their" };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `a` or `an`, for the age or noun that follows it. */
|
|
319
|
+
function article(next) {
|
|
320
|
+
return /^(?:8|11(?!\d)|18(?!\d)|[aeiou])/i.test(next) ? "an" : "a";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The Appearance section's paragraph, in the Folk wording: "Ahmetega Zekar is
|
|
325
|
+
* a 39-year-old man who stands 6'4" tall with a heavy build. He has tawny
|
|
326
|
+
* skin, weathered complexion, black hair, and brown eyes."
|
|
327
|
+
*
|
|
328
|
+
* A fact that is absent leaves its clause out.
|
|
329
|
+
*
|
|
330
|
+
* @param {object} facts
|
|
331
|
+
* @param {string} facts.name - `name.full`.
|
|
332
|
+
* @param {number|null} [facts.age] - Whole years, or `null` where unknown.
|
|
333
|
+
* @param {string} [facts.gender]
|
|
334
|
+
* @param {unknown} [facts.height]
|
|
335
|
+
* @param {string} [facts.frame]
|
|
336
|
+
* @param {object} [facts.appearance] - The `data.appearance` map.
|
|
337
|
+
* @returns {string} The paragraph.
|
|
338
|
+
*/
|
|
339
|
+
export function appearanceParagraph({ name, age, gender, height, frame, appearance = {} }) {
|
|
340
|
+
const who = person(gender);
|
|
341
|
+
const described = age === null || age === undefined ? who.noun : `${age}-year-old ${who.noun}`;
|
|
342
|
+
let first = `${name} is ${article(described)} ${described}`;
|
|
343
|
+
if (height !== undefined && height !== null) first += ` who stands ${proseHeight(height)} tall`;
|
|
344
|
+
if (frame)
|
|
345
|
+
first += ` with ${article(wordLabel("frame", frame))} ${wordLabel("frame", frame)} build`;
|
|
346
|
+
const sentences = [`${first}.`];
|
|
347
|
+
|
|
348
|
+
const look = appearance ?? {};
|
|
349
|
+
const complexion = [look.complexion ?? []]
|
|
350
|
+
.flat()
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.map((w) => wordLabel("appearance.complexion", w));
|
|
353
|
+
const parts = [
|
|
354
|
+
look.skin_color && `${wordLabel("appearance.skin_color", look.skin_color)} skin`,
|
|
355
|
+
complexion.length && `${series(complexion)} complexion`,
|
|
356
|
+
look.hair_color && `${wordLabel("appearance.hair_color", look.hair_color)} hair`,
|
|
357
|
+
look.eye_color && `${wordLabel("appearance.eye_color", look.eye_color)} eyes`,
|
|
358
|
+
].filter(Boolean);
|
|
359
|
+
if (parts.length) sentences.push(`${who.subject} ${who.has} ${series(parts)}.`);
|
|
360
|
+
|
|
361
|
+
const features = (Array.isArray(look.extra_features) ? look.extra_features : []).filter(
|
|
362
|
+
(f) => typeof f === "string" && f.trim(),
|
|
363
|
+
);
|
|
364
|
+
if (features.length) sentences.push(`${who.possessive} features include ${series(features)}.`);
|
|
365
|
+
return sentences.join(" ");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The `data:` keys of a being, in the order a generated note writes them. */
|
|
369
|
+
const DATA_ORDER = Object.freeze([
|
|
370
|
+
"icon",
|
|
371
|
+
"templatePriority",
|
|
372
|
+
"archetypes",
|
|
373
|
+
"occupation",
|
|
374
|
+
"lore",
|
|
375
|
+
"culture",
|
|
376
|
+
"homes",
|
|
377
|
+
"affiliations",
|
|
378
|
+
"gender",
|
|
379
|
+
"species",
|
|
380
|
+
"born",
|
|
381
|
+
"died",
|
|
382
|
+
"height",
|
|
383
|
+
"weight",
|
|
384
|
+
"frame",
|
|
385
|
+
"appearance",
|
|
386
|
+
"packFolder",
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
/** The top-level keys, in the order a generated note writes them. */
|
|
390
|
+
const TOP_ORDER = Object.freeze([
|
|
391
|
+
"shortcode",
|
|
392
|
+
"name",
|
|
393
|
+
"type",
|
|
394
|
+
"subType",
|
|
395
|
+
"description",
|
|
396
|
+
"tags",
|
|
397
|
+
"data",
|
|
398
|
+
]);
|
|
399
|
+
|
|
400
|
+
/** `values`' keys in `order`, then any others in their own order. */
|
|
401
|
+
function ordered(values, order) {
|
|
402
|
+
const out = {};
|
|
403
|
+
for (const key of order) if (given(values[key])) out[key] = values[key];
|
|
404
|
+
for (const [key, value] of Object.entries(values)) if (!(key in out)) out[key] = value;
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The frontmatter as YAML: lists of scalars and the name map in flow style. */
|
|
409
|
+
function frontmatterYaml(frontmatter) {
|
|
410
|
+
const doc = new YAML.Document(frontmatter);
|
|
411
|
+
YAML.visit(doc, {
|
|
412
|
+
Seq(_, node) {
|
|
413
|
+
if (node.items.every((item) => YAML.isScalar(item))) node.flow = true;
|
|
414
|
+
},
|
|
415
|
+
Map(_, node) {
|
|
416
|
+
if (!node.items.length) node.flow = true;
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
const name = doc.get("name", true);
|
|
420
|
+
if (YAML.isMap(name)) name.flow = true;
|
|
421
|
+
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Generate a being note from a spec.
|
|
426
|
+
*
|
|
427
|
+
* @param {object} spec - The spec: frontmatter fields, plus `appearance` and
|
|
428
|
+
* `dossier` section bodies.
|
|
429
|
+
* @param {object} context
|
|
430
|
+
* @param {object} context.profile - The culture's appearance profile, already
|
|
431
|
+
* checked by `profileProblems`.
|
|
432
|
+
* @param {object} context.names - The language's `data.names`, already checked
|
|
433
|
+
* by `namesProblems`.
|
|
434
|
+
* @param {string} [context.language] - The language's Address, for an error
|
|
435
|
+
* about a name that does not fit.
|
|
436
|
+
* @param {string} [context.seed] - Text joined to the spec's own seed, to draw a
|
|
437
|
+
* different person from the same spec.
|
|
438
|
+
* @param {unknown} [context.present] - The package's present, or `null`.
|
|
439
|
+
* @param {object} [context.dateContext] - The reckoning context the present
|
|
440
|
+
* and `born` are read in, with `daysPerYear`.
|
|
441
|
+
* @param {{icon?: unknown, species?: unknown, packFolder?: unknown}} [context.defaults] -
|
|
442
|
+
* Values derived from the package's other beings.
|
|
443
|
+
* @param {ReadonlySet<string>} [context.taken] - Shortcodes the package's beings hold.
|
|
444
|
+
* @returns {{frontmatter: object, body: string, text: string}} The note.
|
|
445
|
+
* @throws {Error} Where the spec has problems, or states a `name.full` that
|
|
446
|
+
* does not fit the language's lists; that error carries `keyPath`
|
|
447
|
+
* `["name", "full"]`.
|
|
448
|
+
*/
|
|
449
|
+
export function generateNpc(spec, context) {
|
|
450
|
+
const problems = npcSpecProblems(spec);
|
|
451
|
+
if (problems.length) throw new Error(problems.join("; "));
|
|
452
|
+
const { profile, names, present = null, dateContext = {}, defaults = {} } = context;
|
|
453
|
+
if (!isPlainMap(names)) throw new Error("`npc new` needs the language's `data.names`");
|
|
454
|
+
const taken = context.taken ?? new Set();
|
|
455
|
+
const seed = canonicalSpec(spec) + (given(context.seed) ? `\u0000${context.seed}` : "");
|
|
456
|
+
const draw = (field) => stream(seed, field);
|
|
457
|
+
const data = isPlainMap(spec.data) ? spec.data : {};
|
|
458
|
+
const look = isPlainMap(data.appearance) ? data.appearance : {};
|
|
459
|
+
const gender = data.gender;
|
|
460
|
+
const statedName = spec.name?.full;
|
|
461
|
+
if (given(statedName)) {
|
|
462
|
+
const misfit = nameMisfit(statedName, names, gender);
|
|
463
|
+
if (misfit)
|
|
464
|
+
throw Object.assign(
|
|
465
|
+
new Error(
|
|
466
|
+
`name.full ${JSON.stringify(statedName)} does not fit the names of ` +
|
|
467
|
+
`${context.language ?? "the language"}: ${misfit}`,
|
|
468
|
+
),
|
|
469
|
+
{ keyPath: ["name", "full"] },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const name = given(statedName) ? statedName : drawName(names, gender, draw("name.full"));
|
|
473
|
+
|
|
474
|
+
const height =
|
|
475
|
+
given(data.height) ?
|
|
476
|
+
data.height
|
|
477
|
+
: frontmatterHeight(drawHeight(heightRange(profile, gender), draw("data.height")));
|
|
478
|
+
const frame =
|
|
479
|
+
given(data.frame) ?
|
|
480
|
+
data.frame
|
|
481
|
+
: (given(data.weight) && frameOf(height, data.weight)) ||
|
|
482
|
+
weighted(profile.frame, draw("data.frame"));
|
|
483
|
+
const weight =
|
|
484
|
+
given(data.weight) ?
|
|
485
|
+
data.weight
|
|
486
|
+
: `${drawWeight(parseBeingHeight(height) ?? 0, frame, draw("data.weight"))} lbs`;
|
|
487
|
+
const agePick = draw("data.born");
|
|
488
|
+
const age = ADULT_AGE.min + Math.floor(agePick() * (ADULT_AGE.max - ADULT_AGE.min + 1));
|
|
489
|
+
const born = given(data.born) ? data.born : drawBorn(age, present, dateContext, agePick);
|
|
490
|
+
|
|
491
|
+
const appearance = { ...look };
|
|
492
|
+
for (const key of profileLookKeys(BEING_LOOK_VOCABULARY)) {
|
|
493
|
+
if (key === "frame" || given(look[key])) continue;
|
|
494
|
+
appearance[key] = weighted(profile[key], draw(`data.appearance.${key}`));
|
|
495
|
+
}
|
|
496
|
+
if (!given(look.extra_features)) appearance.extra_features = [];
|
|
497
|
+
|
|
498
|
+
const generated = {
|
|
499
|
+
templatePriority: null,
|
|
500
|
+
archetypes: ["commoner"],
|
|
501
|
+
occupation: null,
|
|
502
|
+
lore: [],
|
|
503
|
+
homes: [],
|
|
504
|
+
affiliations: {},
|
|
505
|
+
died: null,
|
|
506
|
+
...Object.fromEntries(
|
|
507
|
+
["icon", "species", "packFolder"]
|
|
508
|
+
.filter((key) => given(defaults[key]) && defaults[key] !== null)
|
|
509
|
+
.map((key) => [key, defaults[key]]),
|
|
510
|
+
),
|
|
511
|
+
};
|
|
512
|
+
const fullData = ordered(
|
|
513
|
+
{
|
|
514
|
+
...generated,
|
|
515
|
+
...data,
|
|
516
|
+
born,
|
|
517
|
+
height,
|
|
518
|
+
weight,
|
|
519
|
+
frame,
|
|
520
|
+
appearance: ordered(appearance, [
|
|
521
|
+
...profileLookKeys(BEING_LOOK_VOCABULARY).filter((k) => k !== "frame"),
|
|
522
|
+
"extra_features",
|
|
523
|
+
]),
|
|
524
|
+
},
|
|
525
|
+
DATA_ORDER,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
const frontmatterSpec = Object.fromEntries(
|
|
529
|
+
Object.entries(spec).filter(([key]) => !PROSE_KEYS.includes(key)),
|
|
530
|
+
);
|
|
531
|
+
const frontmatter = ordered(
|
|
532
|
+
{
|
|
533
|
+
shortcode: npcShortcode(name, taken),
|
|
534
|
+
type: "being",
|
|
535
|
+
subType: "npc",
|
|
536
|
+
tags: ["generated", "draft"],
|
|
537
|
+
...frontmatterSpec,
|
|
538
|
+
name: { full: name, ...(isPlainMap(spec.name) ? spec.name : {}) },
|
|
539
|
+
data: fullData,
|
|
540
|
+
},
|
|
541
|
+
TOP_ORDER,
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const computed = computeAge(born, present, dateContext);
|
|
545
|
+
const stated = given(data.age) ? parseAgeMagnitude(data.age)?.value : undefined;
|
|
546
|
+
const paragraph =
|
|
547
|
+
given(spec.appearance) ?
|
|
548
|
+
spec.appearance
|
|
549
|
+
: appearanceParagraph({
|
|
550
|
+
name,
|
|
551
|
+
age: stated ?? computed,
|
|
552
|
+
gender,
|
|
553
|
+
height,
|
|
554
|
+
frame,
|
|
555
|
+
appearance: fullData.appearance,
|
|
556
|
+
});
|
|
557
|
+
const section = (text) => (text.endsWith("\n") ? text : `${text}\n`);
|
|
558
|
+
const body =
|
|
559
|
+
`\n# Appearance {#appearance}\n\n${section(paragraph)}\n# Dossier {#dossier}\n` +
|
|
560
|
+
(given(spec.dossier) && spec.dossier !== "" ? `\n${section(spec.dossier)}` : "");
|
|
561
|
+
return { frontmatter, body, text: `---\n${frontmatterYaml(frontmatter)}---\n${body}` };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The canonical Address a `data.culture` value names, read as a culture
|
|
566
|
+
* reference is: a `lore` note in this package unless it says otherwise.
|
|
567
|
+
*
|
|
568
|
+
* @param {unknown} value - The value as written, or a decoded tuple.
|
|
569
|
+
* @param {object} index - The link index.
|
|
570
|
+
* @returns {string|undefined} The Address, or `undefined` where it reads as none.
|
|
571
|
+
*/
|
|
572
|
+
export function cultureKey(value, index) {
|
|
573
|
+
const tuple = parseAddress(value, {
|
|
574
|
+
package: index?.contentPackage,
|
|
575
|
+
system: "note",
|
|
576
|
+
type: "lore",
|
|
577
|
+
types: index?.types,
|
|
578
|
+
packages: index?.packages,
|
|
579
|
+
});
|
|
580
|
+
return tuple.reason ? undefined : renderAddress(tuple);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* A note's `data:` as its author wrote it, Addresses still in their written
|
|
585
|
+
* form, or `undefined` where the frontmatter does not parse.
|
|
586
|
+
*
|
|
587
|
+
* @param {string|undefined} raw - The note's full text.
|
|
588
|
+
* @returns {object|undefined} The authored `data:` map.
|
|
589
|
+
*/
|
|
590
|
+
function authoredData(raw) {
|
|
591
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(raw ?? "");
|
|
592
|
+
if (!match) return undefined;
|
|
593
|
+
try {
|
|
594
|
+
const data = YAML.parse(match[1])?.data;
|
|
595
|
+
return isPlainMap(data) ? data : undefined;
|
|
596
|
+
} catch {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The commonest value of each of `icon`, `species` and `packFolder` among the
|
|
603
|
+
* package's npcs, as their notes write it: those of the same culture first,
|
|
604
|
+
* then any npc. A tie goes to the value that sorts first.
|
|
605
|
+
*
|
|
606
|
+
* @param {object} index - The link index.
|
|
607
|
+
* @param {string|undefined} culture - The culture's canonical Address.
|
|
608
|
+
* @returns {{icon?: unknown, species?: unknown, packFolder?: unknown, taken: Set<string>}}
|
|
609
|
+
* The defaults, and the shortcodes the package's beings hold.
|
|
610
|
+
*/
|
|
611
|
+
export function npcDefaults(index, culture) {
|
|
612
|
+
const beings = (index?.notes ?? []).filter((note) => note.type === "being");
|
|
613
|
+
const npcs = beings.filter((note) => note.fm?.subType === "npc");
|
|
614
|
+
const kin = npcs.filter(
|
|
615
|
+
(note) => culture && cultureKey(note.fm?.data?.culture, index) === culture,
|
|
616
|
+
);
|
|
617
|
+
const written = new Map(npcs.map((note) => [note, authoredData(note.raw)]));
|
|
618
|
+
const commonest = (notes, key) => {
|
|
619
|
+
const counts = new Map();
|
|
620
|
+
for (const note of notes) {
|
|
621
|
+
const value = written.get(note)?.[key];
|
|
622
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
623
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
624
|
+
}
|
|
625
|
+
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
626
|
+
};
|
|
627
|
+
const out = { taken: new Set(beings.map((note) => note.fm?.shortcode).filter(Boolean)) };
|
|
628
|
+
for (const key of ["icon", "species", "packFolder"]) {
|
|
629
|
+
const value = commonest(kin, key) ?? commonest(npcs, key);
|
|
630
|
+
if (value !== undefined) out[key] = value;
|
|
631
|
+
}
|
|
632
|
+
return out;
|
|
633
|
+
}
|
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",
|