@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,805 @@
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 calendar is a note, the world's facts are a note, and one object is
16
+ * emitted from both.**
17
+ *
18
+ * ## Two layers, and the whole arrangement is the distinction
19
+ *
20
+ * A **world fact** is how long the year is, how the day divides, and how the
21
+ * moon moves. Every people on the world lives under the same ones, so no
22
+ * calendar may restate them: they are read from the package's `place` notes —
23
+ * `data.year` and `data.present` from the world, `data.moon` from the moon —
24
+ * and written by the emitters.
25
+ *
26
+ * A **calendar** is one people's division of those days: the months they name,
27
+ * the weekdays if they keep any, the seasons they mark, and the eras they count
28
+ * years in. That is a `lore` note with `subType: calendar`, and its whole
29
+ * authorable surface is {@link CALENDAR_FIELDS}.
30
+ *
31
+ * Because the invariants are unauthorable, **the sum guard is the only
32
+ * arithmetic a calendar note is held to**: the months must add up to the year
33
+ * the world keeps. A list that does not is not a different calendar, it is a
34
+ * calendar that fails to describe the world — which is what the finding says.
35
+ *
36
+ * ## A package with no world note has no calendar mechanism
37
+ *
38
+ * `daysPerYear` comes from `data.year.days` on the package's own world note. A
39
+ * package that declares none — every Hârn package this toolchain builds —
40
+ * compiles no calendars and is asked no questions about one. A package that
41
+ * declares a moon and no year is refused: a cycle of thirty days has no year to
42
+ * be measured against.
43
+ *
44
+ * ## One definition, two readers
45
+ *
46
+ * The emitted definition is written in **array shape** — `months.values`,
47
+ * `days.values`, `seasons.values` as arrays, which is Foundry core's
48
+ * `ArrayField` shape.
49
+ *
50
+ * - **Core takes it and prunes what it does not know.** `SchemaField#clean`
51
+ * discards every key outside the schema before validation, by default and
52
+ * silently (`common/data/fields.mjs`, `DataModel.cleanData` passing
53
+ * `prune: true`), so `eras`, `moons`, `epochDayOffset` and `metadata` cost a
54
+ * core consumer nothing.
55
+ * - **Calendaria takes the same object and converts the arrays**, in
56
+ * `migrateData`, ahead of validation, on exactly those three collections.
57
+ *
58
+ * So one artefact serves both, and two artefacts drifting is a failure mode
59
+ * this pair does not have. What no test here can prove is schema conformance
60
+ * itself — this package's testing carries no Foundry stubs on purpose — so the
61
+ * consumer build and the Foundry container prove that, and the shapes below
62
+ * claim no more than the emitters produce.
63
+ *
64
+ * @module
65
+ */
66
+
67
+ import { dayOfYear, daysInYear } from "./calendars.mjs";
68
+ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
69
+ import { parseNoteDate } from "./note-dates.mjs";
70
+
71
+ /** The `lore` subType whose notes are calendars. */
72
+ export const CALENDAR_SUBTYPE = "calendar";
73
+
74
+ /**
75
+ * The `place` subTypes a world fact may be written on.
76
+ *
77
+ * `world` is a body people stand on; `celestial` is one they only look at. A
78
+ * region, a settlement, a site, a structure or a feature is somewhere *in* a
79
+ * world and states nothing about the world's year.
80
+ *
81
+ * @type {readonly string[]}
82
+ */
83
+ export const INVARIANT_SUBTYPES = Object.freeze(["world", "celestial"]);
84
+
85
+ /**
86
+ * The `data:` keys a calendar note may write — the closed list, in authored
87
+ * order.
88
+ *
89
+ * Every key in either target schema that is not here is either a world fact,
90
+ * read from a `place` note and written by the emitters, or omitted because no
91
+ * calendar has declared one.
92
+ *
93
+ * @type {readonly object[]}
94
+ */
95
+ export const CALENDAR_FIELDS = Object.freeze([
96
+ {
97
+ name: "epoch",
98
+ shape: "a date",
99
+ kind: "string",
100
+ describe: "Which in-world day the world's clock reads zero on — `720/1/1`.",
101
+ },
102
+ {
103
+ name: "months",
104
+ shape: "list of `{ name, abbreviation?, days }`",
105
+ kind: "list",
106
+ describe:
107
+ "The months this calendar keeps, in order — position in the list is " +
108
+ "position in the year, and the day counts sum to the world's year.",
109
+ },
110
+ {
111
+ name: "weekdays",
112
+ shape: "list of `{ name, abbreviation? }`",
113
+ kind: "list",
114
+ describe:
115
+ "The days of the week this calendar names, in order. A calendar with " +
116
+ "no week writes none.",
117
+ },
118
+ {
119
+ name: "seasons",
120
+ shape: "list of `{ name, abbreviation?, monthStart?, monthEnd?, dayStart?, dayEnd? }`",
121
+ kind: "list",
122
+ describe: "The seasons this calendar marks, bounded by month or by day of year.",
123
+ },
124
+ {
125
+ name: "eras",
126
+ shape: "list of `{ shortcode, name, abbreviation?, proclaimedBy?, start, end? }`",
127
+ kind: "list",
128
+ describe:
129
+ "The year-counts kept in this calendar. A date names one by writing " +
130
+ "`<calendar shortcode>.<era shortcode>`.",
131
+ },
132
+ ]);
133
+
134
+ /**
135
+ * The `data:` keys a world or a celestial body may write.
136
+ *
137
+ * Four groups, and which note carries which is a fact about the body rather
138
+ * than a convention: a planet states its own size, tilt, year and present; a
139
+ * moon states its own size, orbit and cycle.
140
+ *
141
+ * @type {readonly object[]}
142
+ */
143
+ export const INVARIANT_FIELDS = Object.freeze([
144
+ {
145
+ name: "world.equatorialCircumferenceKm",
146
+ shape: "number",
147
+ kind: "number",
148
+ describe: "The distance round the body at its equator, in kilometres.",
149
+ },
150
+ {
151
+ name: "world.surfaceGravityG",
152
+ shape: "number",
153
+ kind: "number",
154
+ describe: "Surface gravity, as a multiple of Earth's.",
155
+ },
156
+ {
157
+ name: "world.axialTiltDegrees",
158
+ shape: "number",
159
+ kind: "number",
160
+ describe: "The tilt of the axis, in degrees — what makes the body have seasons.",
161
+ },
162
+ {
163
+ name: "year.days",
164
+ shape: "number",
165
+ kind: "number",
166
+ describe: "How many days the body's year holds. Every calendar's months sum to it.",
167
+ },
168
+ {
169
+ name: "year.hoursPerDay",
170
+ shape: "number",
171
+ kind: "number",
172
+ describe: "How many hours the day divides into.",
173
+ },
174
+ {
175
+ name: "year.minutesPerHour",
176
+ shape: "number",
177
+ kind: "number",
178
+ describe: "How many minutes the hour divides into.",
179
+ },
180
+ {
181
+ name: "year.secondsPerMinute",
182
+ shape: "number",
183
+ kind: "number",
184
+ describe: "How many seconds the minute divides into.",
185
+ },
186
+ {
187
+ name: "present",
188
+ shape: "a date",
189
+ kind: "string",
190
+ describe: "The day the setting stops and play begins — what an age is computed against.",
191
+ },
192
+ {
193
+ name: "body.diameterKm",
194
+ shape: "number",
195
+ kind: "number",
196
+ describe: "The body's diameter, in kilometres.",
197
+ },
198
+ {
199
+ name: "body.orbitalRadiusKm",
200
+ shape: "number",
201
+ kind: "number",
202
+ describe: "How far the body orbits from the one it circles, in kilometres.",
203
+ },
204
+ {
205
+ name: "body.orbit",
206
+ shape: "string",
207
+ kind: "string",
208
+ describe: "The orbit's shape — `circular` means the cycle never varies.",
209
+ },
210
+ {
211
+ name: "body.inclined",
212
+ shape: "boolean",
213
+ kind: "boolean",
214
+ describe: "Whether the orbit is inclined to the plane the world orbits in.",
215
+ },
216
+ {
217
+ name: "moon.cycle",
218
+ shape: "number",
219
+ kind: "number",
220
+ describe: "How many days the body takes to return to the same phase.",
221
+ },
222
+ {
223
+ name: "moon.newOn",
224
+ shape: "a date",
225
+ kind: "string",
226
+ describe: "A day the body was new, written in the reference calendar.",
227
+ },
228
+ {
229
+ name: "moon.eclipses",
230
+ shape: "string",
231
+ kind: "string",
232
+ describe:
233
+ "How often the body eclipses or is eclipsed — `never`, `rare`, " +
234
+ "`occasional` or `frequent`.",
235
+ },
236
+ ]);
237
+
238
+ /** First segment of every calendar key, for the check that refuses them elsewhere. */
239
+ const CALENDAR_KEYS = Object.freeze(CALENDAR_FIELDS.map((field) => field.name));
240
+
241
+ /** First segment of every world-fact key, for the check that scopes them. */
242
+ const INVARIANT_KEYS = Object.freeze([
243
+ ...new Set(INVARIANT_FIELDS.map((field) => field.name.split(".")[0])),
244
+ ]);
245
+
246
+ /** The `data:` map of a note, or an empty one. */
247
+ function dataOf(note) {
248
+ const data = note?.fm?.data;
249
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
250
+ }
251
+
252
+ /** A note's type, lower-cased, whether the walk recorded it or only the frontmatter did. */
253
+ function typeOf(note) {
254
+ return String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
255
+ }
256
+
257
+ /** A note's subType, lower-cased. */
258
+ function subTypeOf(note) {
259
+ return String(note?.fm?.subType ?? "").toLowerCase();
260
+ }
261
+
262
+ /** Which of a key list a note actually writes. */
263
+ function keysWritten(note, keys) {
264
+ const data = dataOf(note);
265
+ return keys.filter((key) => data[key] !== undefined && data[key] !== null);
266
+ }
267
+
268
+ /**
269
+ * The world facts a package declares, read from its own notes.
270
+ *
271
+ * Nothing here is configured and nothing is assumed: a package with no world
272
+ * note has no year, and every question that depends on one goes unasked.
273
+ *
274
+ * @param {object} [index] - The link index, holding every note in the package.
275
+ * @returns {{year: object|null, present: unknown, moon: object|null,
276
+ * body: object|null, moonName: string|null, yearNote: object|null,
277
+ * moonNote: object|null}} What the package's notes state.
278
+ */
279
+ export function worldInvariants(index) {
280
+ const empty = {
281
+ year: null,
282
+ present: null,
283
+ moon: null,
284
+ body: null,
285
+ moonName: null,
286
+ yearNote: null,
287
+ moonNote: null,
288
+ };
289
+ if (!index) return empty;
290
+ const found = { ...empty };
291
+ for (const note of index.notes ?? []) {
292
+ if (typeOf(note) !== "place") continue;
293
+ const data = dataOf(note);
294
+ if (!found.yearNote && data.year && typeof data.year === "object") {
295
+ found.year = data.year;
296
+ found.present = data.present ?? null;
297
+ found.yearNote = note;
298
+ }
299
+ if (!found.moonNote && data.moon && typeof data.moon === "object") {
300
+ found.moon = data.moon;
301
+ found.body = data.body && typeof data.body === "object" ? data.body : null;
302
+ found.moonName = String(note.fm?.name?.full ?? "") || null;
303
+ found.moonNote = note;
304
+ }
305
+ }
306
+ return found;
307
+ }
308
+
309
+ /**
310
+ * Every `place` note in the package carrying one of the world-fact groups.
311
+ *
312
+ * Sorted by path so a package declaring two reports the same pair whichever
313
+ * order the corpus walk happened to take.
314
+ *
315
+ * @param {object} index - The link index.
316
+ * @param {string} key - The group — `year` or `moon`.
317
+ * @returns {object[]} The notes, by path.
318
+ */
319
+ function notesCarrying(index, key) {
320
+ const carrying = [];
321
+ for (const note of index.notes ?? []) {
322
+ if (typeOf(note) !== "place") continue;
323
+ const held = dataOf(note)[key];
324
+ if (held && typeof held === "object") carrying.push(note);
325
+ }
326
+ return carrying.sort((a, b) => String(a.rel ?? a.file).localeCompare(String(b.rel ?? b.file)));
327
+ }
328
+
329
+ /** A finding positioned at a `data:` key of a note. */
330
+ function atData(note, keyPath, severity, message) {
331
+ return {
332
+ file: note.file,
333
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", ...keyPath], { key: true }),
334
+ severity,
335
+ message,
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Check a `place` note's world facts.
341
+ *
342
+ * Declared on `place` beside its tenure check, so the lint runs both with the
343
+ * same index. Four rules, and each is a fact about the setting rather than
344
+ * about the file:
345
+ *
346
+ * - a world fact is written on a body — a `world` or a `celestial` — and
347
+ * nowhere else;
348
+ * - a package states **one** year, because two years is two settings and
349
+ * nothing can choose between them;
350
+ * - a package states **one** moon, for the same reason;
351
+ * - a moon with no year is refused, because a cycle measured in days has
352
+ * nothing to be measured against.
353
+ *
354
+ * `data.present` is read through the parser a note's dates go through, with the
355
+ * corpus in hand, so a present written in an era resolves where it is written.
356
+ *
357
+ * @param {object} note - The note, as the link index hands it over.
358
+ * @param {object} [opts]
359
+ * @param {object} [opts.index] - The link index, holding every note.
360
+ * @returns {object[]} Findings.
361
+ */
362
+ export function checkWorldFacts(note, { index } = {}) {
363
+ if (typeOf(note) !== "place") return [];
364
+ const findings = [];
365
+ const written = keysWritten(note, INVARIANT_KEYS);
366
+ if (written.length === 0) return findings;
367
+
368
+ const subType = subTypeOf(note);
369
+ if (!INVARIANT_SUBTYPES.includes(subType)) {
370
+ for (const key of written) {
371
+ findings.push(
372
+ atData(
373
+ note,
374
+ [key],
375
+ "error",
376
+ `\`data.${key}\` states a fact about a body, and this is a ` +
377
+ `${subType || "place"} within one — write it on the ` +
378
+ `\`world\` or \`celestial\` note whose subject the body is`,
379
+ ),
380
+ );
381
+ }
382
+ return findings;
383
+ }
384
+
385
+ if (written.includes("present")) {
386
+ const { findings: dated } = parseNoteDate(dataOf(note).present, {
387
+ field: "data.present",
388
+ allowUnknown: false,
389
+ file: note.file,
390
+ raw: note.raw,
391
+ keyPath: ["data", "present"],
392
+ });
393
+ findings.push(...dated);
394
+ }
395
+
396
+ // The corpus questions, which need every note and so are asked only where
397
+ // the caller handed one over.
398
+ if (!index) return findings;
399
+
400
+ for (const key of ["year", "moon"]) {
401
+ if (!written.includes(key)) continue;
402
+ const carrying = notesCarrying(index, key);
403
+ if (carrying.length < 2) continue;
404
+ const others = carrying
405
+ .filter((other) => other !== note)
406
+ .map((other) => other.rel ?? other.file);
407
+ findings.push(
408
+ atData(
409
+ note,
410
+ [key],
411
+ "error",
412
+ `a package states one ${key === "year" ? "year" : "moon"}, and ` +
413
+ `${others.join(", ")} states one too — two is two settings, and ` +
414
+ `nothing can say which one a calendar is written against`,
415
+ ),
416
+ );
417
+ }
418
+
419
+ if (written.includes("moon") && notesCarrying(index, "year").length === 0) {
420
+ findings.push(
421
+ atData(
422
+ note,
423
+ ["moon"],
424
+ "error",
425
+ "a cycle of days has no year to be measured against: no note in " +
426
+ "this package states `data.year`, so nothing says how long the " +
427
+ "year this moon runs against is",
428
+ ),
429
+ );
430
+ }
431
+
432
+ return findings;
433
+ }
434
+
435
+ /**
436
+ * Check a `lore` note's calendar declaration.
437
+ *
438
+ * Both halves of one sentence, and the second is the half that keeps the family
439
+ * from leaking: a calendar note must carry its months and its epoch, and a lore
440
+ * note that is not a calendar must carry none of the family at all.
441
+ *
442
+ * The sum is the only arithmetic. It runs where the package states a year and
443
+ * the note states months, and nowhere else — neither fires on a package that
444
+ * declared nothing.
445
+ *
446
+ * A calendar note **cannot** restate a world fact, and it needs no rule of its
447
+ * own to stop it: `data.year`, `data.moon`, `data.world` and `data.body` are
448
+ * declared on `place` and not on `lore`, so the closed `data:` container
449
+ * already refuses them by the rule it refuses every undeclared key by.
450
+ *
451
+ * @param {object} note - The note, as the link index hands it over.
452
+ * @param {object} [opts]
453
+ * @param {object} [opts.index] - The link index, for the world's year.
454
+ * @returns {object[]} Findings.
455
+ */
456
+ export function checkCalendarNote(note, { index } = {}) {
457
+ if (typeOf(note) !== "lore") return [];
458
+ const findings = [];
459
+ const written = keysWritten(note, CALENDAR_KEYS);
460
+
461
+ if (subTypeOf(note) !== CALENDAR_SUBTYPE) {
462
+ for (const key of written) {
463
+ findings.push(
464
+ atData(
465
+ note,
466
+ [key],
467
+ "error",
468
+ `\`data.${key}\` is a calendar's, and this note's \`subType\` is ` +
469
+ `not \`calendar\` — move it to the note about the calendar, ` +
470
+ `which every body keeping that calendar can then link to`,
471
+ ),
472
+ );
473
+ }
474
+ return findings;
475
+ }
476
+
477
+ for (const required of ["months", "epoch"]) {
478
+ if (written.includes(required)) continue;
479
+ findings.push({
480
+ file: note.file,
481
+ ...positionInFrontmatter(note.raw ?? "", "subType", undefined, { topLevel: true }),
482
+ severity: "error",
483
+ message:
484
+ `a calendar divides the year and says where the count begins, so ` +
485
+ `this note must state \`data.${required}\``,
486
+ });
487
+ }
488
+
489
+ findings.push(...checkMonthSum(note, index));
490
+ findings.push(...checkEraShortcodes(note));
491
+ return findings;
492
+ }
493
+
494
+ /**
495
+ * The sum-to-the-world's-year finding.
496
+ *
497
+ * Positioned on the list rather than on an entry: the pass holds a column of
498
+ * numbers and no way to know which one was mistyped, and a position pointed at
499
+ * the wrong entry is worse than a position on the list.
500
+ *
501
+ * The message states what is wrong with the world rather than with the
502
+ * addition, because the remedy is to decide which month is the wrong length and
503
+ * not to make the column balance.
504
+ *
505
+ * @param {object} note - The calendar note.
506
+ * @param {object} [index] - The link index.
507
+ * @returns {object[]} Findings.
508
+ */
509
+ function checkMonthSum(note, index) {
510
+ const months = dataOf(note).months;
511
+ if (!Array.isArray(months) || months.length === 0) return [];
512
+ const days = worldInvariants(index).year?.days;
513
+ if (typeof days !== "number") return [];
514
+ const sum = daysInYear(months);
515
+ if (sum === days) return [];
516
+ return [
517
+ atData(
518
+ note,
519
+ ["months"],
520
+ "error",
521
+ `\`data.months\` sums to ${sum}, and the year is ${days} days — a ` +
522
+ `calendar names the same days differently, it does not keep a ` +
523
+ `different number of them`,
524
+ ),
525
+ ];
526
+ }
527
+
528
+ /**
529
+ * One calendar must not count two eras by the same name.
530
+ *
531
+ * A shortcode is unique within its `(type, package)` by construction, so a
532
+ * qualified era name cannot collide across calendars and there is no
533
+ * corpus-wide sweep to run. What is left is local, and verifiable against this
534
+ * note alone.
535
+ *
536
+ * @param {object} note - The calendar note.
537
+ * @returns {object[]} Findings.
538
+ */
539
+ function checkEraShortcodes(note) {
540
+ const eras = dataOf(note).eras;
541
+ if (!Array.isArray(eras)) return [];
542
+ const seen = new Set();
543
+ const findings = [];
544
+ eras.forEach((era, position) => {
545
+ const shortcode = String(era?.shortcode ?? "");
546
+ if (!shortcode) {
547
+ findings.push(
548
+ atData(
549
+ note,
550
+ ["eras", position],
551
+ "error",
552
+ "an era is addressed `<calendar shortcode>.<era shortcode>`, so " +
553
+ "every row states a `shortcode` of its own",
554
+ ),
555
+ );
556
+ return;
557
+ }
558
+ if (seen.has(shortcode)) {
559
+ findings.push(
560
+ atData(
561
+ note,
562
+ ["eras", position],
563
+ "error",
564
+ `this calendar already counts an era called "${shortcode}", and a ` +
565
+ `date naming it would name both`,
566
+ ),
567
+ );
568
+ }
569
+ seen.add(shortcode);
570
+ });
571
+ return findings;
572
+ }
573
+
574
+ /**
575
+ * The four days the year turns on, by day of year.
576
+ *
577
+ * The year opens on the first of the first month, and the year's length sets
578
+ * the spacing; nothing else is consulted. A body's tilt is what makes seasons
579
+ * exist at all, and it is read by nobody — a quarter day is where the calendar
580
+ * opens plus a quarter of the year, to the nearest whole day, because a day is
581
+ * the smallest thing a calendar can name.
582
+ *
583
+ * @param {number} daysPerYear - The world's year.
584
+ * @returns {number[]} Four day-of-year numbers, in order from the year's start.
585
+ */
586
+ export function quarterDays(daysPerYear) {
587
+ return [0, 1, 2, 3].map((quarter) => Math.round(1 + (daysPerYear * quarter) / 4));
588
+ }
589
+
590
+ /** Drop the keys whose value is `undefined`, so an omitted field is absent. */
591
+ function written(object) {
592
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
593
+ }
594
+
595
+ /** A month's name, abbreviation and length, plus its position and its kind. */
596
+ function monthValues(months) {
597
+ const lengths = months.map((month) => Number(month?.days) || 0);
598
+ // Calendaria's own bundled calendars mark a short month this way, and it is
599
+ // display metadata rather than arithmetic: a month shorter than the length
600
+ // most months share is the days a people keeps outside their months.
601
+ const modal = [...lengths]
602
+ .sort(
603
+ (a, b) => lengths.filter((l) => l === b).length - lengths.filter((l) => l === a).length,
604
+ )
605
+ .at(0);
606
+ return months.map((month, position) =>
607
+ written({
608
+ name: String(month?.name ?? ""),
609
+ abbreviation:
610
+ month?.abbreviation === undefined ? undefined : String(month.abbreviation),
611
+ ordinal: position + 1,
612
+ days: lengths[position],
613
+ // `leapDays` is never written. A world with no leap rule has no
614
+ // second length for a month, and a value that could disagree with
615
+ // `days` is one that eventually will.
616
+ type: lengths[position] < modal ? "intercalary" : undefined,
617
+ }),
618
+ );
619
+ }
620
+
621
+ /** A weekday's name, abbreviation and position. An empty list stays empty. */
622
+ function weekdayValues(weekdays) {
623
+ return (Array.isArray(weekdays) ? weekdays : []).map((day, position) =>
624
+ written({
625
+ name: String(day?.name ?? ""),
626
+ abbreviation: day?.abbreviation === undefined ? undefined : String(day.abbreviation),
627
+ ordinal: position + 1,
628
+ }),
629
+ );
630
+ }
631
+
632
+ /** A season's name and whichever pair of bounds the note wrote. */
633
+ function seasonValues(seasons) {
634
+ return (Array.isArray(seasons) ? seasons : []).map((season) =>
635
+ written({
636
+ name: String(season?.name ?? ""),
637
+ abbreviation:
638
+ season?.abbreviation === undefined ? undefined : String(season.abbreviation),
639
+ monthStart: season?.monthStart,
640
+ monthEnd: season?.monthEnd,
641
+ dayStart: season?.dayStart,
642
+ dayEnd: season?.dayEnd,
643
+ }),
644
+ );
645
+ }
646
+
647
+ /** The era rows, keyed by shortcode, with the addressing segments dropped. */
648
+ function eraEntries(eras) {
649
+ const entries = {};
650
+ for (const era of Array.isArray(eras) ? eras : []) {
651
+ const shortcode = String(era?.shortcode ?? "");
652
+ if (!shortcode) continue;
653
+ entries[shortcode] = written({
654
+ name: String(era?.name ?? ""),
655
+ abbreviation: era?.abbreviation === undefined ? undefined : String(era.abbreviation),
656
+ start: era?.start === undefined ? undefined : String(era.start),
657
+ end: era?.end === undefined ? undefined : String(era.end),
658
+ });
659
+ }
660
+ return entries;
661
+ }
662
+
663
+ /**
664
+ * The moon entry, from the moon's own note and nothing else.
665
+ *
666
+ * Every value is the world fact stated in the target's vocabulary, and where
667
+ * the setting models nothing the target's own way of saying nothing is written:
668
+ * eclipses that nobody computes are `eclipseMode` with no `nodalPeriod`, and a
669
+ * circular orbit is a cycle that never varies.
670
+ *
671
+ * @param {object} moon - `data.moon`, from the moon note.
672
+ * @param {object|null} body - `data.body`, from the same note.
673
+ * @param {string} name - What the body is called.
674
+ * @returns {object|null} The entry, keyed by the body's name, or `null`.
675
+ */
676
+ function moonEntries(moon, body, name) {
677
+ if (!moon || !name) return null;
678
+ const date = parseNoteDate(moon.newOn, { allowUnknown: false }).date;
679
+ return {
680
+ [name]: written({
681
+ name,
682
+ cycleLength: Number(moon.cycle),
683
+ referenceDate:
684
+ date ? written({ year: date.year, month: date.month, day: date.day }) : undefined,
685
+ // A circular orbit is a cycle of exactly its length, every time.
686
+ cycleVariance: String(body?.orbit ?? "") === "circular" ? 0 : undefined,
687
+ phaseMode: "fixed",
688
+ eclipseMode: moon.eclipses === undefined ? undefined : String(moon.eclipses),
689
+ // Stated rather than fabricated: an inclined orbit makes eclipses
690
+ // uncommon, and nothing in the setting says when one falls, so
691
+ // there is no period to write.
692
+ nodalPeriod: null,
693
+ }),
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Compile one calendar note and the package's world facts into one definition.
699
+ *
700
+ * The object both consumers read: array-shaped for core's `ArrayField`
701
+ * collections, carrying the keys core prunes and Calendaria keeps.
702
+ *
703
+ * @param {object} opts
704
+ * @param {object} opts.note - The calendar note.
705
+ * @param {object} opts.invariants - What {@link worldInvariants} read.
706
+ * @param {string} [opts.contentPackage] - The package shipping the note, which
707
+ * is a fact about the tree rather than about the note: a note never declares
708
+ * its own package.
709
+ * @returns {object} The definition.
710
+ */
711
+ export function compileCalendar({ note, invariants, contentPackage }) {
712
+ const data = dataOf(note);
713
+ const year = invariants?.year ?? {};
714
+ const months = Array.isArray(data.months) ? data.months : [];
715
+ const epoch = parseNoteDate(data.epoch, { allowUnknown: false }).date;
716
+ const shortcode = String(note.fm?.shortcode ?? "");
717
+ const name = String(note.fm?.name?.full ?? "");
718
+ const description = String(note.fm?.description ?? "");
719
+ const moons = moonEntries(invariants?.moon, invariants?.body ?? null, invariants?.moonName);
720
+ const author = contentPackage ? String(contentPackage) : undefined;
721
+
722
+ return written({
723
+ name,
724
+ description,
725
+ years: {
726
+ // The year the epoch falls in is the year a reader is shown at the
727
+ // world's own zero.
728
+ yearZero: epoch?.year ?? 0,
729
+ // Required, non-nullable and with no initial, so it must be
730
+ // written; with no weekdays it indexes nothing and 0 is the only
731
+ // defensible value.
732
+ firstWeekday: 0,
733
+ // A world with no leap rule states that it has none, rather than
734
+ // leaving a shape for one to be inferred into.
735
+ leapYear: null,
736
+ },
737
+ months: { values: monthValues(months) },
738
+ days: written({
739
+ values: weekdayValues(data.weekdays),
740
+ daysPerYear: year.days,
741
+ hoursPerDay: year.hoursPerDay,
742
+ minutesPerHour: year.minutesPerHour,
743
+ secondsPerMinute: year.secondsPerMinute,
744
+ }),
745
+ seasons: { values: seasonValues(data.seasons) },
746
+ // How far into the year the world's zero falls. `1/1` is none.
747
+ epochDayOffset:
748
+ epoch && months.length ? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1) - 1 : 0,
749
+ eras: eraEntries(data.eras),
750
+ moons: moons ?? undefined,
751
+ metadata: written({
752
+ id: shortcode || undefined,
753
+ description,
754
+ author,
755
+ }),
756
+ });
757
+ }
758
+
759
+ /**
760
+ * Wrap a definition in the envelope a Calendaria settings import reads.
761
+ *
762
+ * `calendarData` is the definition unchanged — the importer derives an id from
763
+ * its `name` and creates a custom calendar from it — so the envelope adds a
764
+ * header and nothing else. That is what makes the two artefacts assertable
765
+ * against each other rather than merely similar.
766
+ *
767
+ * @param {object} definition - What {@link compileCalendar} produced.
768
+ * @param {object} opts
769
+ * @param {string} opts.version - The Calendaria version this was generated
770
+ * against, which the importer records.
771
+ * @param {string} opts.exportedAt - When, as an ISO 8601 instant.
772
+ * @returns {object} The envelope.
773
+ */
774
+ export function calendariaEnvelope(definition, { version, exportedAt }) {
775
+ return {
776
+ version,
777
+ exportedAt,
778
+ settings: {},
779
+ calendarData: definition,
780
+ };
781
+ }
782
+
783
+ /**
784
+ * Every calendar a package declares, compiled.
785
+ *
786
+ * A package whose notes state no year declares no calendars and is asked
787
+ * nothing about one, which is the right answer for a package that builds a
788
+ * setting somebody else wrote down.
789
+ *
790
+ * @param {object} [index] - The link index, holding every note.
791
+ * @param {object} [opts]
792
+ * @param {string} [opts.contentPackage] - The package shipping the tree.
793
+ * @returns {{calendars: object[], invariants: object}} One compiled definition
794
+ * per calendar note, and the facts they were compiled against.
795
+ */
796
+ export function compileCalendars(index, { contentPackage } = {}) {
797
+ const invariants = worldInvariants(index);
798
+ if (!invariants.year) return { calendars: [], invariants };
799
+ const calendars = [];
800
+ for (const note of index?.notes ?? []) {
801
+ if (typeOf(note) !== "lore" || subTypeOf(note) !== CALENDAR_SUBTYPE) continue;
802
+ calendars.push(compileCalendar({ note, invariants, contentPackage }));
803
+ }
804
+ return { calendars, invariants };
805
+ }