@heroiclands/package-build 22.8.0 → 22.9.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,270 @@
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 middle state of `age`: absent beside a dated `born`, it follows from
16
+ * the date.**
17
+ *
18
+ * `age` is a three-state override — authored it wins, absent beside a dated
19
+ * `born` it is computed, absent beside `born: unknown` or an absent `born` it
20
+ * stays unknown. This module builds the middle state, which nothing else in
21
+ * the toolchain does: `born` and `age` are read straight through to every
22
+ * compiled surface exactly as authored, unless a note's own package states the
23
+ * day the setting stops.
24
+ *
25
+ * ## Where the present comes from
26
+ *
27
+ * A world's `present` is a fact about the world, not about a calendar or a
28
+ * build configuration — it is `data.present` on the package's own `place` note
29
+ * of `subType: world` (or `celestial`), read through
30
+ * {@link module:engine/calendar-notes.worldInvariants}. A package with no such
31
+ * note states no present, computes no age, and says nothing about it: the
32
+ * absence is silent, exactly as a package that declares no calendar is asked
33
+ * no calendar questions.
34
+ *
35
+ * ## The arithmetic is on two dates, not on two years
36
+ *
37
+ * A year subtracted from a year is wrong whenever the birthday has not yet
38
+ * happened in the present's year — the common shape, not the exception. So
39
+ * {@link computeAge} compares `born`'s month and day against `present`'s,
40
+ * **ordinally**, and drops a year where born's has not yet arrived. That
41
+ * ordinal comparison needs no month-length table: two dates written in one
42
+ * calendar order the same way whether the comparison counts days-of-year
43
+ * through the calendar's month lengths or simply reads `(month, day)` as a
44
+ * pair, because a calendar's months keep the same lengths every year — this
45
+ * package's calendars carry no leap rule (`calendar-notes.mjs`'s
46
+ * `years.leapYear` is always `null`). So the comparison is calendar-agnostic
47
+ * by construction, and correct for a twelve-month calendar of thirty-day
48
+ * months, a ten-month calendar, or none at all — a bare `born` with no era
49
+ * names no calendar to consult in the first place.
50
+ *
51
+ * ## `~` follows the convention {@link module:engine/note-dates} sets
52
+ *
53
+ * A date's `~` marks the value approximate without changing what it parses to,
54
+ * so a renderer keeps the authored text and a comparison still has a number.
55
+ * {@link parseAgeMagnitude} draws the same distinction for `age` — the tilde,
56
+ * the magnitude — without routing through {@link module:engine/note-dates}
57
+ * itself: `age` is a plain count of years, not a date, so it has no month, no
58
+ * day, no era, and — unlike a date — no refusal of `0`. Reusing the date
59
+ * grammar's month/day/era machinery here would accept an age nothing about the
60
+ * field means, and refuse the one age an infant can have.
61
+ *
62
+ * @module
63
+ */
64
+
65
+ import { worldInvariants } from "./calendar-notes.mjs";
66
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
67
+ import { parseNoteDate } from "./note-dates.mjs";
68
+ // `born`'s retired spelling is still read during its retirement window — see
69
+ // `docs/content-format.md`'s "`birthday:` is the retired spelling of `born:`"
70
+ // — so a being on the old spelling still computes an age rather than reading
71
+ // as never born.
72
+ import { readAliasedField } from "./retired-fields.mjs";
73
+
74
+ /** The note type this module's derivation and check apply to. */
75
+ const BEING_TYPE = "being";
76
+
77
+ /** Whether a note's type — read either directly or through its frontmatter — is `being`. */
78
+ function isBeingType(type) {
79
+ return String(type ?? "").toLowerCase() === BEING_TYPE;
80
+ }
81
+
82
+ /** The `data:` map of a note's frontmatter, or `undefined` where there is none to read. */
83
+ function dataOf(fm) {
84
+ const data = fm?.data;
85
+ return data && typeof data === "object" && !Array.isArray(data) ? data : undefined;
86
+ }
87
+
88
+ /**
89
+ * `~34`'s tilde and its magnitude — the same distinction a date's `~` draws,
90
+ * asked of a plain count of years rather than of a calendar date.
91
+ *
92
+ * A whole, non-negative number only: an age is never fractional and never
93
+ * before an epoch, so neither a date's sign nor its month/day/era grammar
94
+ * belongs here.
95
+ *
96
+ * @type {RegExp}
97
+ */
98
+ const AGE_MAGNITUDE_PATTERN = /^(~)?\s*(\d+)\s*$/;
99
+
100
+ /**
101
+ * Parse an authored `age` into its estimate mark and its magnitude.
102
+ *
103
+ * @param {unknown} raw - The authored value — `34`, `"34"` or `"~34"`.
104
+ * @returns {{approximate: boolean, value: number}|null} The parts, or `null`
105
+ * for a value that does not read as a plain count of years.
106
+ */
107
+ export function parseAgeMagnitude(raw) {
108
+ if (typeof raw !== "number" && typeof raw !== "string") return null;
109
+ const match = AGE_MAGNITUDE_PATTERN.exec(String(raw).trim());
110
+ if (!match) return null;
111
+ const [, tilde, digits] = match;
112
+ return { approximate: tilde === "~", value: Number(digits) };
113
+ }
114
+
115
+ /**
116
+ * The age two dates imply, in whole years, computed from the dates themselves
117
+ * rather than from the years alone.
118
+ *
119
+ * @param {unknown} bornRaw - The being's authored `born`, or `undefined`/`null`
120
+ * where it wrote none.
121
+ * @param {unknown} presentRaw - The package's `data.present`, as
122
+ * {@link module:engine/calendar-notes.worldInvariants} read it, or
123
+ * `undefined`/`null` where the package states none.
124
+ * @returns {number|null} The age, or `null` where `born` is `unknown` or
125
+ * absent, where no present is declared, or where either date fails to parse.
126
+ */
127
+ export function computeAge(bornRaw, presentRaw) {
128
+ if (bornRaw === undefined || bornRaw === null) return null;
129
+ if (presentRaw === undefined || presentRaw === null) return null;
130
+
131
+ const born = parseNoteDate(bornRaw, { allowUnknown: true }).date;
132
+ if (!born || !born.known || born.canonicalYear == null) return null;
133
+
134
+ const present = parseNoteDate(presentRaw, { allowUnknown: false }).date;
135
+ if (!present || !present.known || present.canonicalYear == null) return null;
136
+
137
+ // Ordinal position within the year, day of year or not: see the module
138
+ // doc for why `(month, day)` alone orders correctly here. Missing month or
139
+ // day reads as the year's first day — the earliest a birthday could fall —
140
+ // so a `born` written to less precision than `present` never counts as
141
+ // "not yet happened".
142
+ const bornOrdinal = (born.month ?? 1) * 100 + (born.day ?? 1);
143
+ const presentOrdinal = (present.month ?? 1) * 100 + (present.day ?? 1);
144
+ const notYetHappened = bornOrdinal > presentOrdinal;
145
+
146
+ return present.canonicalYear - born.canonicalYear - (notYetHappened ? 1 : 0);
147
+ }
148
+
149
+ /**
150
+ * Fill in a being's computed `age`, and the numeric companion an estimate
151
+ * carries beside it — mutating the frontmatter object handed in, never a file
152
+ * on disk.
153
+ *
154
+ * Every caller holds a frontmatter object that was parsed fresh from a note's
155
+ * text for its own pass — the content index, a compiled document, a rendered
156
+ * page — and none of those objects round-trips back to the `.md` file. So
157
+ * patching `data.age` in place here is exactly as safe as reading it, and
158
+ * reaches every surface that reads `data:` after this runs, with the note
159
+ * itself untouched.
160
+ *
161
+ * @param {object} fm - A note's frontmatter, as freshly parsed.
162
+ * @param {unknown} presentRaw - The package's declared present, or
163
+ * `undefined`/`null` where it states none.
164
+ * @returns {void}
165
+ */
166
+ export function applyComputedBeingAge(fm, presentRaw) {
167
+ if (!isBeingType(fm?.type)) return;
168
+ const data = dataOf(fm);
169
+ if (!data) return;
170
+
171
+ const authored = data.age;
172
+ if (authored !== undefined && authored !== null && authored !== "") {
173
+ // An authored age always wins — the value itself is never touched.
174
+ // `~34` is kept exactly as written; its normalised number is added
175
+ // beside it, the way a date's `canonicalYear` sits beside its `text`.
176
+ const parsed = parseAgeMagnitude(authored);
177
+ if (parsed?.approximate) data.ageYears = parsed.value;
178
+ return;
179
+ }
180
+
181
+ const born = readAliasedField(fm, "born", { inData: true });
182
+ const computed = computeAge(born, presentRaw);
183
+ if (computed !== null) data.age = computed;
184
+ }
185
+
186
+ /**
187
+ * A being's `data.present`, read from every `place` note a batch of records or
188
+ * frontmatter objects carries.
189
+ *
190
+ * Two shapes reach this by way of two thin adapters, because the callers that
191
+ * need a present hold their notes two different ways: a flat index record
192
+ * (`type` and `data` at the top level) and a raw walk's `{frontmatter}` pair.
193
+ * Neither shape is the `{fm, type}` pair {@link worldInvariants} reads, so each
194
+ * adapter states the one conversion its caller needs rather than asking every
195
+ * caller to know it.
196
+ *
197
+ * @param {readonly object[]|undefined} records - Flat index records — a
198
+ * content-index record, or anything else spreading a note's frontmatter to
199
+ * its own top level.
200
+ * @returns {unknown} The present, or `null` where no `place` note in the batch
201
+ * declares one.
202
+ */
203
+ export function presentAmongRecords(records) {
204
+ return worldInvariants({
205
+ notes: (records ?? []).map((record) => ({ fm: record, type: record?.type })),
206
+ }).present;
207
+ }
208
+
209
+ /**
210
+ * The present read from a batch of raw frontmatter objects.
211
+ *
212
+ * @param {readonly (object|null|undefined)[]|undefined} frontmatters - One
213
+ * entry per note walked, as parsed — never a record.
214
+ * @returns {unknown} The present, or `null` where none is declared.
215
+ */
216
+ export function presentAmongFrontmatters(frontmatters) {
217
+ return worldInvariants({
218
+ notes: (frontmatters ?? []).map((fm) => ({ fm, type: fm?.type })),
219
+ }).present;
220
+ }
221
+
222
+ /**
223
+ * Check a being's authored `age` against what `born` and the package's present
224
+ * compute.
225
+ *
226
+ * Registered as `being`'s whole-note `check` in `note-vocabulary.mjs`, run the
227
+ * way `checkPlace` and `checkCalendarNote` are — beside every other check the
228
+ * closed `data:` container earns, with the same `index`.
229
+ *
230
+ * Two silences, both deliberate: beside `born: unknown` or an absent `born`
231
+ * there is nothing to disagree with, and a package that declares no present
232
+ * computes no age to disagree with either — both cases are read by
233
+ * {@link computeAge} returning `null`, and neither says a word.
234
+ *
235
+ * @param {object} note - The note, as the link index hands it over —
236
+ * {@link module:engine/calendar-notes.worldInvariants}'s `index.notes` shape.
237
+ * @param {object} [opts]
238
+ * @param {object} [opts.index] - The link index, holding the package's `place`
239
+ * notes. Its absence skips the check, the way a reference check with no
240
+ * index skips reporting every reference as dead.
241
+ * @returns {object[]} Findings — at most one `warning`, naming both values.
242
+ */
243
+ export function checkBeingAge(note, { index } = {}) {
244
+ if (!isBeingType(note?.type ?? note?.fm?.type)) return [];
245
+ const data = dataOf(note?.fm);
246
+ if (!data) return [];
247
+
248
+ const bornRaw = readAliasedField(note?.fm, "born", { inData: true });
249
+ const ageRaw = data.age;
250
+ if (ageRaw === undefined || ageRaw === null || ageRaw === "") return [];
251
+ if (!index) return [];
252
+
253
+ const present = worldInvariants(index).present;
254
+ const computed = computeAge(bornRaw, present);
255
+ if (computed === null) return [];
256
+
257
+ const authored = parseAgeMagnitude(ageRaw);
258
+ if (!authored || authored.value === computed) return [];
259
+
260
+ return [
261
+ {
262
+ file: note.file,
263
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "age"], { key: true }),
264
+ severity: "warning",
265
+ message:
266
+ `\`data.age: ${JSON.stringify(ageRaw)}\` disagrees with ` +
267
+ `\`data.born: ${JSON.stringify(bornRaw)}\`, which computes to ${computed}`,
268
+ },
269
+ ];
270
+ }