@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.
@@ -443,12 +443,20 @@ These fields are accepted by every note type. A field's value can still be irrel
443
443
 
444
444
  **Subtypes:** `social`, `nature`, `craft`, `lore`, `language`, `script`, `mystical`, `physical`, `combat`, `combattechnique`.
445
445
 
446
- | Field | Shape | Meaning |
447
- | ----------------------- | ---------- | -------------------------------------------------------------------- |
448
- | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
449
- | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
450
- | `data.masteryLevel` | number | Mastery before any modifier. |
451
- | `data.parentSkill` | an Address | The skill this one specialises. |
446
+ | Field | Shape | Meaning |
447
+ | ----------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
448
+ | `data.icon` | an Address | The document's profile art — an `icon` address, resolved into `img`. |
449
+ | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
450
+ | `data.masteryLevel` | number | Mastery before any modifier. |
451
+ | `data.parentSkill` | an Address | The skill this one specialises. |
452
+ | `data.names` | a language's name lists | How this language's speakers are named, for `package-build npc new`: `pattern` builds a name from placeholders such as "{given} {clan}" and literal text, `given` maps a gender to a list of given names, `female` and `male` required, and every other key is a list of names a placeholder of the same name draws from. A language skill note only. See [name lists](format-details.md#name-lists). |
453
+
454
+ **Inner keys** — a key a field's value does not declare is an error at its own line and column.
455
+
456
+ | Key | Shape | Required | Meaning |
457
+ | -------------------- | --------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
458
+ | `data.names.pattern` | a pattern such as "{given} {clan}", a list of them, or a map of genders to either | yes | How a name is built: each `{list}` placeholder is filled from the list of that name, `{given.<gender>}` from that gender's given names, and the text around them is written as it stands. A list states every form a name may take; a map keyed by gender states each gender's forms, and a gender with none takes every pattern stated. |
459
+ | `data.names.given` | a map of genders to lists of names | yes | Given names by gender word. A gender with no list draws from every given list together. |
452
460
 
453
461
  **System mappings**
454
462
 
@@ -516,83 +524,93 @@ These fields are accepted by every note type. A field's value can still be irrel
516
524
 
517
525
  Lore records in-world knowledge. A `culture` describes a people; a `custom` describes how they practice a rite, observance, or usage. A `material` describes a physical constituent and its qualities, which may vary by region. See the [lore subtype definitions](format-details.md#type-lore) for the complete vocabulary.
518
526
 
519
- | Field | Shape | Meaning |
520
- | ---------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
521
- | `data.epoch` | a canonical `<year>.<day>` | Canonical day when calendar year 1, day 1 begins — `1.1`. |
522
- | `data.months` | list of `{ name, abbreviation?, days }` | The months this calendar keeps, in order — position in the list is position in the year, and the day counts sum to the world's year. |
523
- | `data.weekdays` | list of `{ name, abbreviation? }` | The days of the week this calendar names, in order. A calendar with no week writes none. |
524
- | `data.seasons` | list of `{ name, abbreviation?, start }` | The seasons this calendar marks, starting on numbered days of the year. |
525
- | `data.namedDays` | list of `{ name, abbreviation?, day }` | Names assigned to particular days of the year. |
526
- | `data.eras` | list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }` | The year-counts kept in this calendar. A marker names one era and uses these months. |
527
- | `data.formats` | map of named Calendaria format strings | Named patterns for reading and writing this calendar's dates. |
528
- | `data.culture` | an Address | The people this lore belongs to — for a work of literature, the people whose work it is — as a culture lore note. A culture note is its own culture, and states the field only as itself. |
529
- | `data.form` | string | The kind of work in its people's own terms — an epic, a saga, a praise-song, an elegy. Free text. |
530
- | `data.subjects` | list of Addresses | The beings, places, gods and other notes the work concerns, and each event it concerns, named as `place-ironfells#sack`. |
531
- | `data.language` | an Address | The tongue the work is composed in, as a language skill note. |
532
- | `data.events` | list of event entries | What happened to or at this subject — each entry one dated, attributed event: a founding, a war, a fall, or an occasion that recurs. |
527
+ | Field | Shape | Meaning |
528
+ | ------------------------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
529
+ | `data.epoch` | a canonical `<year>.<day>` | Canonical day when calendar year 1, day 1 begins — `1.1`. |
530
+ | `data.months` | list of `{ name, abbreviation?, days }` | The months this calendar keeps, in order — position in the list is position in the year, and the day counts sum to the world's year. |
531
+ | `data.weekdays` | list of `{ name, abbreviation? }` | The days of the week this calendar names, in order. A calendar with no week writes none. |
532
+ | `data.seasons` | list of `{ name, abbreviation?, start }` | The seasons this calendar marks, starting on numbered days of the year. |
533
+ | `data.namedDays` | list of `{ name, abbreviation?, day }` | Names assigned to particular days of the year. |
534
+ | `data.eras` | list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }` | The year-counts kept in this calendar. A marker names one era and uses these months. |
535
+ | `data.formats` | map of named Calendaria format strings | Named patterns for reading and writing this calendar's dates. |
536
+ | `data.parents` | list of Addresses | Culture only: parent culture lore notes, searched in listed depth-first order for omitted language and appearance profile fields. |
537
+ | `data.culture` | an Address | The people this lore belongs to — for a work of literature, the people whose work it is — as a culture lore note. A culture note is its own culture, and states the field only as itself. |
538
+ | `data.form` | string | The kind of work in its people's own terms — an epic, a saga, a praise-song, an elegy. Free text. |
539
+ | `data.subjects` | list of Addresses | The beings, places, gods and other notes the work concerns, and each event it concerns, named as `place-ironfells#sack`. |
540
+ | `data.language` | an Address | The tongue the work is composed in, or on a culture note the tongue its people speak, as a language skill note. `package-build npc new` draws a culture's names from its language's `data.names`. A `literature` or `culture` note only. |
541
+ | `data.events` | list of event entries | What happened to or at this subject — each entry one dated, attributed event: a founding, a war, a fall, or an occasion that recurs. |
542
+ | `data.appearanceProfile` | an appearance profile | What this culture's people look like, for `package-build npc new`: `frame`, `eye_color`, `hair_color`, `skin_color` and `complexion` each map words of that field's vocabulary to positive weights, and `height` maps a gender to a `{min, max}` range, `female` and `male` required. A culture note only. See [appearance profiles](format-details.md#appearance-profiles). |
533
543
 
534
544
  **Inner keys** — a key a field's value does not declare is an error at its own line and column.
535
545
 
536
- | Key | Shape | Required | Meaning |
537
- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------- |
538
- | `data.months[].name` | a string | yes | What it is called. |
539
- | `data.months[].abbreviation` | a string | no | Its short form, for a compact date. |
540
- | `data.months[].days` | a whole number of days | yes | How many days the month holds. |
541
- | `data.weekdays[].name` | a string | yes | What it is called. |
542
- | `data.weekdays[].abbreviation` | a string | no | Its short form, for a compact date. |
543
- | `data.seasons[].name` | a string | yes | What it is called. |
544
- | `data.seasons[].abbreviation` | a string | no | Its short form, for a compact date. |
545
- | `data.seasons[].start` | a one-based day of the year, as a whole number | yes | The day of the year the season begins. |
546
- | `data.namedDays[].name` | a string | yes | What it is called. |
547
- | `data.namedDays[].abbreviation` | a string | no | Its short form, for a compact date. |
548
- | `data.namedDays[].day` | a one-based day of the year, as a whole number | yes | The day of the year it names. |
549
- | `data.eras[].shortcode` | the era's own shortcode, unique within the calendar — an era is addressed `<calendar shortcode>.<era shortcode>` | yes | The era's own segment of `<calendar>.<era>`. |
550
- | `data.eras[].name` | a string | yes | What it is called. |
551
- | `data.eras[].marker` | uppercase letters and digits, beginning with a letter | no | The marker an authored date names the era by, unique across the corpus. |
552
- | `data.eras[].abbreviation` | a string | no | Its short form, for a compact date. |
553
- | `data.eras[].proclaimedBy` | an Address | no | The body that began the reckoning. |
554
- | `data.eras[].start` | `null`, or the calendar year the era begins, as a whole number | yes | The calendar year the era begins; `null` for the era before year 1. |
555
- | `data.eras[].label` | a string, or an `{ after, before }` map | no | How a date in the era reads, around one `{date}` slot. |
556
- | `data.eras[].label.after` | a string with one `{date}` slot | no | How a date in the era reads. |
557
- | `data.eras[].label.before` | a string with one `{date}` slot | no | How a date before the era's start reads. |
558
- | `data.events[].id` | an address segment | no | The event's name within its note — lowercase letters and digits. |
559
- | `data.events[].kind` | one `eventKind` value | no | What sort of thing happened. |
560
- | `data.events[].depth` | one `depth` value | no | How far the event's history reaches. |
561
- | `data.events[].when` | a date, or `"0.<day>"` | yes | When it happened, or the anchor and first instance of an event that recurs. |
562
- | `data.events[].until` | a date | no | When a continuous event ended, or where a recurring series stopped. |
563
- | `data.events[].recurs` | `{ every }` or `{ on }` | no | How further occurrences are found; absent for an event that happened once. |
564
- | `data.events[].recurs.every` | a whole number of years, 1 or more | no | A period counted on the canonical axis from `when`. |
565
- | `data.events[].recurs.on` | a list of dates, strictly increasing, each later than `when` | no | Recorded occurrences beyond the first, in place of a period. |
566
- | `data.events[].summary` | a string | yes | What happened, stated plainly. |
567
- | `data.events[].standing` | one `standing` value | no | What the world's evidence supports. |
568
- | `data.events[].names` | `{ name, by, gloss? }[]` | no | The event's names in the world, each with who uses it. |
569
- | `data.events[].names[].name` | a string | yes | One name the event goes by in the world. |
570
- | `data.events[].names[].by` | an Address naming an `affiliation`, `lore`, `place`, `being` or `skill` note | yes | Who uses that name — a people, a polity, a faith, a place, a tongue. |
571
- | `data.events[].names[].gloss` | a string | no | What the name means, or how it is used. |
572
- | `data.events[].where` | `{ locus?, reach? }` | no | Where it happened, and where it was felt. |
573
- | `data.events[].where.locus` | a list of Addresses, each defaulting to `place` | no | Where the event physically happened; each resolves to a place. |
574
- | `data.events[].where.reach` | `{ place, how, knowledge, attributedTo? }[]` | no | Where the event was felt, and whether each place knows why. |
575
- | `data.events[].where.reach[].place` | an Address, defaulting to `place` | yes | A place where the event was felt. |
576
- | `data.events[].where.reach[].how` | a string | yes | One clause: a consequence someone in that place could notice. |
577
- | `data.events[].where.reach[].knowledge` | one `knowledge` value | yes | Whether that place connects what it felt to this event. |
578
- | `data.events[].where.reach[].attributedTo` | an Address, defaulting to `lore`, naming an event or a `lore` note | no | Only beside `knowledge: misattributed` — the cause that place names instead. |
579
- | `data.events[].who` | `{ ref, role }[]` | no | Who took part, and as what. |
580
- | `data.events[].who[].ref` | an Address naming a `being`, `affiliation` or `lore` note | yes | A participant — a being, a people, an affiliation. |
581
- | `data.events[].who[].role` | one `role` value | yes | What the participant was to the event. |
582
- | `data.events[].follows` | `{ event, how, note? }[]` | no | The earlier events this one follows from. |
583
- | `data.events[].follows[].event` | an Address, defaulting to `lore`, anchored to an `event` where its note holds several | yes | The earlier event; it resolves to exactly one event. |
584
- | `data.events[].follows[].how` | one `followsHow` value | yes | How this event follows from it. |
585
- | `data.events[].follows[].note` | a string | no | One clause saying what connects the two. |
586
- | `data.events[].accounts` | `{ by, says, agrees, withholds? }[]` | no | What each people, polity, faith or place says about it. |
587
- | `data.events[].accounts[].by` | an Address naming an `affiliation`, `lore`, `place` or `being` note | yes | Who holds the account. |
588
- | `data.events[].accounts[].says` | a string | yes | What they say happened, in their terms. |
589
- | `data.events[].accounts[].agrees` | one `agrees` value | yes | How far their account agrees with `summary`. |
590
- | `data.events[].accounts[].withholds` | a string | no | What they decline to say. |
591
- | `data.events[].unresolved` | a list of strings | no | What the world itself has not settled. |
592
- | `data.events[].sources` | a list of Addresses | no | The notes that state or support the event; each resolves. |
593
- | `data.events[].stated` | `{ calendar, text }` | no | The date as one tradition writes it in its own reckoning. |
594
- | `data.events[].stated.calendar` | an Address, or a lore shortcode | yes | The calendar that tradition reckons in — a `lore` note with `subType: calendar`. |
595
- | `data.events[].stated.text` | a string | yes | The date as that tradition writes it. |
546
+ | Key | Shape | Required | Meaning |
547
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ |
548
+ | `data.months[].name` | a string | yes | What it is called. |
549
+ | `data.months[].abbreviation` | a string | no | Its short form, for a compact date. |
550
+ | `data.months[].days` | a whole number of days | yes | How many days the month holds. |
551
+ | `data.weekdays[].name` | a string | yes | What it is called. |
552
+ | `data.weekdays[].abbreviation` | a string | no | Its short form, for a compact date. |
553
+ | `data.seasons[].name` | a string | yes | What it is called. |
554
+ | `data.seasons[].abbreviation` | a string | no | Its short form, for a compact date. |
555
+ | `data.seasons[].start` | a one-based day of the year, as a whole number | yes | The day of the year the season begins. |
556
+ | `data.namedDays[].name` | a string | yes | What it is called. |
557
+ | `data.namedDays[].abbreviation` | a string | no | Its short form, for a compact date. |
558
+ | `data.namedDays[].day` | a one-based day of the year, as a whole number | yes | The day of the year it names. |
559
+ | `data.eras[].shortcode` | the era's own shortcode, unique within the calendar — an era is addressed `<calendar shortcode>.<era shortcode>` | yes | The era's own segment of `<calendar>.<era>`. |
560
+ | `data.eras[].name` | a string | yes | What it is called. |
561
+ | `data.eras[].marker` | uppercase letters and digits, beginning with a letter | no | The marker an authored date names the era by, unique across the corpus. |
562
+ | `data.eras[].abbreviation` | a string | no | Its short form, for a compact date. |
563
+ | `data.eras[].proclaimedBy` | an Address | no | The body that began the reckoning. |
564
+ | `data.eras[].start` | `null`, or the calendar year the era begins, as a whole number | yes | The calendar year the era begins; `null` for the era before year 1. |
565
+ | `data.eras[].label` | a string, or an `{ after, before }` map | no | How a date in the era reads, around one `{date}` slot. |
566
+ | `data.eras[].label.after` | a string with one `{date}` slot | no | How a date in the era reads. |
567
+ | `data.eras[].label.before` | a string with one `{date}` slot | no | How a date before the era's start reads. |
568
+ | `data.events[].id` | an address segment | no | The event's name within its note — lowercase letters and digits. |
569
+ | `data.events[].kind` | one `eventKind` value | no | What sort of thing happened. |
570
+ | `data.events[].depth` | one `depth` value | no | How far the event's history reaches. |
571
+ | `data.events[].when` | a date, or `"0.<day>"` | yes | When it happened, or the anchor and first instance of an event that recurs. |
572
+ | `data.events[].until` | a date | no | When a continuous event ended, or where a recurring series stopped. |
573
+ | `data.events[].recurs` | `{ every }` or `{ on }` | no | How further occurrences are found; absent for an event that happened once. |
574
+ | `data.events[].recurs.every` | a whole number of years, 1 or more | no | A period counted on the canonical axis from `when`. |
575
+ | `data.events[].recurs.on` | a list of dates, strictly increasing, each later than `when` | no | Recorded occurrences beyond the first, in place of a period. |
576
+ | `data.events[].summary` | a string | yes | What happened, stated plainly. |
577
+ | `data.events[].standing` | one `standing` value | no | What the world's evidence supports. |
578
+ | `data.events[].names` | `{ name, by, gloss? }[]` | no | The event's names in the world, each with who uses it. |
579
+ | `data.events[].names[].name` | a string | yes | One name the event goes by in the world. |
580
+ | `data.events[].names[].by` | an Address naming an `affiliation`, `lore`, `place`, `being` or `skill` note | yes | Who uses that name — a people, a polity, a faith, a place, a tongue. |
581
+ | `data.events[].names[].gloss` | a string | no | What the name means, or how it is used. |
582
+ | `data.events[].where` | `{ locus?, reach? }` | no | Where it happened, and where it was felt. |
583
+ | `data.events[].where.locus` | a list of Addresses, each defaulting to `place` | no | Where the event physically happened; each resolves to a place. |
584
+ | `data.events[].where.reach` | `{ place, how, knowledge, attributedTo? }[]` | no | Where the event was felt, and whether each place knows why. |
585
+ | `data.events[].where.reach[].place` | an Address, defaulting to `place` | yes | A place where the event was felt. |
586
+ | `data.events[].where.reach[].how` | a string | yes | One clause: a consequence someone in that place could notice. |
587
+ | `data.events[].where.reach[].knowledge` | one `knowledge` value | yes | Whether that place connects what it felt to this event. |
588
+ | `data.events[].where.reach[].attributedTo` | an Address, defaulting to `lore`, naming an event or a `lore` note | no | Only beside `knowledge: misattributed` — the cause that place names instead. |
589
+ | `data.events[].who` | `{ ref, role }[]` | no | Who took part, and as what. |
590
+ | `data.events[].who[].ref` | an Address naming a `being`, `affiliation` or `lore` note | yes | A participant — a being, a people, an affiliation. |
591
+ | `data.events[].who[].role` | one `role` value | yes | What the participant was to the event. |
592
+ | `data.events[].follows` | `{ event, how, note? }[]` | no | The earlier events this one follows from. |
593
+ | `data.events[].follows[].event` | an Address, defaulting to `lore`, anchored to an `event` where its note holds several | yes | The earlier event; it resolves to exactly one event. |
594
+ | `data.events[].follows[].how` | one `followsHow` value | yes | How this event follows from it. |
595
+ | `data.events[].follows[].note` | a string | no | One clause saying what connects the two. |
596
+ | `data.events[].accounts` | `{ by, says, agrees, withholds? }[]` | no | What each people, polity, faith or place says about it. |
597
+ | `data.events[].accounts[].by` | an Address naming an `affiliation`, `lore`, `place` or `being` note | yes | Who holds the account. |
598
+ | `data.events[].accounts[].says` | a string | yes | What they say happened, in their terms. |
599
+ | `data.events[].accounts[].agrees` | one `agrees` value | yes | How far their account agrees with `summary`. |
600
+ | `data.events[].accounts[].withholds` | a string | no | What they decline to say. |
601
+ | `data.events[].unresolved` | a list of strings | no | What the world itself has not settled. |
602
+ | `data.events[].sources` | a list of Addresses | no | The notes that state or support the event; each resolves. |
603
+ | `data.events[].stated` | `{ calendar, text }` | no | The date as one tradition writes it in its own reckoning. |
604
+ | `data.events[].stated.calendar` | an Address, or a lore shortcode | yes | The calendar that tradition reckons in — a `lore` note with `subType: calendar`. |
605
+ | `data.events[].stated.text` | a string | yes | The date as that tradition writes it. |
606
+ | `data.appearanceProfile.frame` | a map of words to weights | yes | Weighted `frame` words; a word left out is never drawn. |
607
+ | `data.appearanceProfile.eye_color` | a map of words to weights | yes | Weighted `eye_color` words; a word left out is never drawn. |
608
+ | `data.appearanceProfile.hair_color` | a map of words to weights | yes | Weighted `hair_color` words; a word left out is never drawn. |
609
+ | `data.appearanceProfile.skin_color` | a map of words to weights | yes | Weighted `skin_color` words; a word left out is never drawn. |
610
+ | `data.appearanceProfile.complexion` | a map of words to weights | yes | Weighted `complexion` words; a word left out is never drawn. |
611
+ | `data.appearanceProfile.height` | a map of genders to height ranges | yes | Height ranges keyed by gender; a gender with no range draws from the span of every range stated. |
612
+ | `data.appearanceProfile.height.<name>.min` | a height in metres or feet and inches | yes | The shortest height drawn. |
613
+ | `data.appearanceProfile.height.<name>.max` | a height in metres or feet and inches | yes | The tallest height drawn. |
596
614
 
597
615
  ### map
598
616
 
@@ -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
@@ -246,6 +247,7 @@ types:
246
247
  subTypes: []
247
248
  lore:
248
249
  data:
250
+ - parents
249
251
  - epoch
250
252
  - months
251
253
  - weekdays
@@ -258,6 +260,7 @@ types:
258
260
  - subjects
259
261
  - language
260
262
  - events
263
+ - appearanceProfile
261
264
  subTypes:
262
265
  - cosmology
263
266
  - deity