@heroiclands/package-build 22.19.0 → 22.20.0

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Content checks require characters and NPCs to name an archetype and reject unrecognized archetypes; creatures may omit them.
8
+ - Names with unsupported fields now fail validation, and aliases appear beneath the name in infoboxes across published formats.
9
+
3
10
  ## 22.19.0
4
11
 
5
12
  ### Minor Changes
@@ -7,7 +7,11 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { formatGenerated } from "../engine/format-generated.mjs";
9
9
  import { loadContentFormat } from "../engine/content-format.mjs";
10
- import { NOTE_VOCABULARY, SHARED_DATA_FIELDS } from "../engine/note-vocabulary.mjs";
10
+ import {
11
+ BEING_ARCHETYPES,
12
+ NOTE_VOCABULARY,
13
+ SHARED_DATA_FIELDS,
14
+ } from "../engine/note-vocabulary.mjs";
11
15
 
12
16
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
17
  const output = path.join(root, "docs/reference/note-types.md");
@@ -104,3 +108,29 @@ if (process.argv.includes("--check")) {
104
108
  } else {
105
109
  process.stdout.write(result);
106
110
  }
111
+
112
+ const detailsPath = path.join(root, "docs/reference/format-details.md");
113
+ const details = fs.readFileSync(detailsPath, "utf8");
114
+ if (!/<!-- archetypes:start -->[\s\S]*?<!-- archetypes:end -->/.test(details)) {
115
+ throw new Error("The archetype reference requires its generation markers");
116
+ }
117
+ const archetypes = Object.entries(BEING_ARCHETYPES)
118
+ .map(([name, meaning]) => `- ${name}: ${meaning}`)
119
+ .join("\n");
120
+ const updatedDetails = await formatGenerated(
121
+ details.replace(
122
+ /<!-- archetypes:start -->[\s\S]*?<!-- archetypes:end -->/,
123
+ `<!-- archetypes:start -->\n${archetypes}\n<!-- archetypes:end -->`,
124
+ ),
125
+ detailsPath,
126
+ );
127
+ if (process.argv.includes("--check")) {
128
+ if (details !== updatedDetails) {
129
+ console.error(
130
+ "docs/reference/format-details.md: error: run node ci/content-format-reference.mjs to refresh the archetype reference",
131
+ );
132
+ process.exitCode = 1;
133
+ }
134
+ } else {
135
+ fs.writeFileSync(detailsPath, updatedDetails);
136
+ }
package/docs/api.md CHANGED
@@ -353,6 +353,7 @@ An addressed content note accepts exactly these top-level keys, in this order: `
353
353
  | ------------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------- |
354
354
  | `DRAFT_TAG` | `const DRAFT_TAG` | — | naming the declared tag that marks a note as unfinished |
355
355
  | `GM_TAG` | `const GM_TAG` | — | naming the declared tag that marks a GM-only note |
356
+ | `BEING_ARCHETYPES` | `const BEING_ARCHETYPES` | `Readonly<Record<string, string>>` | looking up the case-sensitive being archetypes and their meanings |
356
357
  | `NOTE_VOCABULARY` | `const NOTE_VOCABULARY` | — | looking up, per content type, its `data:` field list and its closed `subType` values |
357
358
  | `SHARED_DATA_FIELDS` | `const SHARED_DATA_FIELDS` | `readonly DataFieldSpec[]` | reading the `data:` keys every type accepts, whatever it is — the art slots legal everywhere |
358
359
  | `DECLARED_TAGS` | `const DECLARED_TAGS` | — | looking up the tags a note type may declare, grouped |
@@ -21,7 +21,9 @@ data: {}
21
21
  ---
22
22
  ```
23
23
 
24
- `name.full` is the displayed name; `name.aliases` helps search. `description` is the short page summary. `tags` hold draft state and descriptive labels. The note type's classification belongs in `subType` where it is declared. `data` holds facts about the subject shared by systems. A YAML comment is the place for an author-only note: commented values remain valid YAML but do not enter the content index or generated documents.
24
+ `name` is a map with a required, nonempty `full` display name. Every note may also use `aliases` for an ordered list of other names. The note infobox lists aliases directly beneath the full name. For a `being` with `subType: character`, `given` and `clan` are optional name components. The first alias of a character or NPC is its nickname. Omit optional keys when they do not apply; `given`, `clan`, and alias entries must be nonempty strings when present. No other keys are accepted under `name`.
25
+
26
+ `description` is the short page summary. `tags` hold draft state and descriptive labels. The note type's classification belongs in `subType` where it is declared. `data` holds facts about the subject shared by systems. A YAML comment is the place for an author-only note: commented values remain valid YAML but do not enter the content index or generated documents.
25
27
 
26
28
  Use `tags: [gm]` for a note intended only for the GM. It is absent from the public website and book. Foundry includes it only when its document routes to a pack with `private: true`. A note with `gm` that also creates a prose JournalEntry needs a private JournalEntry pack. Links from untagged notes to GM notes are errors; GM notes may link to one another.
27
29
 
@@ -117,7 +117,8 @@ Item link.
117
117
  order** — the very table each `### type:` section below states. There is no
118
118
  second list, so a key added to a type appears in the book, on the website and
119
119
  in a compendium journal with no further edit. A `Name` row stands first on
120
- every note, because every note has one.
120
+ every note, because every note has one. An `Aliases` row follows it when the
121
+ note names any aliases, preserving their authored order.
121
122
 
122
123
  A few keys carry no row, and the reason in each case is one of exactly two —
123
124
  the key is **machinery**, steering a build or an interface rather than
@@ -333,6 +334,13 @@ sohl: {}
333
334
  Any other top-level key is an error. To keep an author-only note beside the
334
335
  content, comment out its YAML lines with `#`. Comments remain valid YAML text
335
336
  and do not become data in the JSONL index, generated pages, or Foundry documents.
337
+
338
+ `name` is a map with a required nonempty `full` string when present. It may
339
+ also contain `aliases`, an ordered list of nonempty strings. A being with
340
+ `subType: character` may additionally contain nonempty `given` and `clan`
341
+ strings. Other keys under `name` are errors. For a character or NPC, the first
342
+ alias is its nickname. A being's homes belong in `data.homes`.
343
+
336
344
  There is no custom site metadata. `description` supplies the page's short
337
345
  description; `tags` carries publication and classification tags.
338
346
 
@@ -2432,6 +2440,8 @@ For JournalEntries, the following rules apply:
2432
2440
 
2433
2441
  **Archetypes** describe characters in broad terms and help match them to an adventure. The list is:
2434
2442
 
2443
+ <!-- archetypes:start -->
2444
+
2435
2445
  - warrior: Can hold a line and win a fight.
2436
2446
  - skirmisher: Fights light — ambush, missile, mobility.
2437
2447
  - infiltrator: Gets in unseen — locks, stealth, disguise.
@@ -2448,6 +2458,10 @@ For JournalEntries, the following rules apply:
2448
2458
  - entertainer: Performs for an audience through acting, music, comedy, or similar arts.
2449
2459
  - guildsperson: Has professional training, standing, or connections that open doors.
2450
2460
 
2461
+ <!-- archetypes:end -->
2462
+
2463
+ `character` and `npc` beings require at least one archetype. `creature` beings may omit archetypes or write an empty array. Values use the exact lowercase spellings above.
2464
+
2451
2465
  Note that archetypes are descriptive, not proscriptive, and a character may be described by multiple archetypes at once.
2452
2466
  `commoner` stands alone: a being with another fitting archetype does not also carry `commoner`.
2453
2467
  Guild membership alone does not require `guildsperson`; the being's training, standing, or connections must matter to the role.
@@ -2462,34 +2476,34 @@ An Item can mark its readable description with `# ... {#description}`. That head
2462
2476
 
2463
2477
  Generates a living (or undead, or spirit) being.
2464
2478
 
2465
- | `data` property | Values | Description |
2466
- | --------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------ |
2467
- | `tokenIcon` | `Address` | What a token on the canvas wears — an `icon` address; defaults to `icon` |
2468
- | `templatePriority` | `number` | Template priority, _null_ = not a template |
2469
- | `archetypes` | `Archetype[]` | What sort of character this is. **Always an array** — `[]` where none apply; `null` is an error. |
2470
- | `occupation` | `string` | Name of the character's occupation |
2471
- | `social` | `Map<string, unknown>` | The being's social profile |
2472
- | `stations` | `Address[]` | Name of the stations the character belongs to |
2473
- | `lore` | `Address[]` | Other lore concerning this being, such as social standing or law |
2474
- | `culture` | `Address` | Primary culture, naming a lore note with `subType: culture` |
2475
- | `homes` | `Address[]` | Place the being calls home |
2476
- | `affiliations` | `Address[]` | Affilliations (e.g., arcane/divine traditions, polities, etc) |
2477
- | `socialTies` | `Map<Address, Tie>` | Defining support and opposition, directed from this being to others |
2478
- | `gender` | `male \| female \| other` | Gender of the character |
2479
- | `species` | `Address` | Being's species (lore) |
2480
- | `born` | `date \| unknown` | When the being was born; absent, it was never born |
2481
- | `calendar` | `Address` | Calendar note used to print the being's dates |
2482
- | `died` | `date \| unknown` | When the being died; absent, it is alive |
2483
- | `age` | `34 \| ~34` | Age in years, stated only to override what `born` says; `~` marks an estimate |
2484
- | `ageYears` | `number` | Written by the compiler beside an `age` estimate — the `~` stripped; a note never authors this |
2485
- | `height` | `1.91m \| 6' 3"` | Height in metres or feet and inches |
2486
- | `weight` | `85kg \| 187 lbs` | Body weight in kilograms or pounds |
2487
- | `frame` | `scant \| light \| medium \| large \| massive` | Relative frame size |
2488
- | `appearance.eye_color` | `string` | Eye color |
2489
- | `appearance.hair_color` | `string` | Hair color |
2490
- | `appearance.skin_color` | `string` | Skin color |
2491
- | `appearance.complexion` | `string` | Complexion |
2492
- | `appearance.extra_features` | `string[]` | Extra features |
2479
+ | `data` property | Values | Description |
2480
+ | --------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
2481
+ | `tokenIcon` | `Address` | What a token on the canvas wears — an `icon` address; defaults to `icon` |
2482
+ | `templatePriority` | `number` | Template priority, _null_ = not a template |
2483
+ | `archetypes` | `Archetype[]` | What sort of character this is. **Always an array** — character and npc require at least one; creature may omit it or write `[]`; `null` is an error. |
2484
+ | `occupation` | `string` | Name of the character's occupation |
2485
+ | `social` | `Map<string, unknown>` | The being's social profile |
2486
+ | `stations` | `Address[]` | Name of the stations the character belongs to |
2487
+ | `lore` | `Address[]` | Other lore concerning this being, such as social standing or law |
2488
+ | `culture` | `Address` | Primary culture, naming a lore note with `subType: culture` |
2489
+ | `homes` | `Address[]` | Place the being calls home |
2490
+ | `affiliations` | `Address[]` | Affilliations (e.g., arcane/divine traditions, polities, etc) |
2491
+ | `socialTies` | `Map<Address, Tie>` | Defining support and opposition, directed from this being to others |
2492
+ | `gender` | `male \| female \| other` | Gender of the character |
2493
+ | `species` | `Address` | Being's species (lore) |
2494
+ | `born` | `date \| unknown` | When the being was born; absent, it was never born |
2495
+ | `calendar` | `Address` | Calendar note used to print the being's dates |
2496
+ | `died` | `date \| unknown` | When the being died; absent, it is alive |
2497
+ | `age` | `34 \| ~34` | Age in years, stated only to override what `born` says; `~` marks an estimate |
2498
+ | `ageYears` | `number` | Written by the compiler beside an `age` estimate — the `~` stripped; a note never authors this |
2499
+ | `height` | `1.91m \| 6' 3"` | Height in metres or feet and inches |
2500
+ | `weight` | `85kg \| 187 lbs` | Body weight in kilograms or pounds |
2501
+ | `frame` | `scant \| light \| medium \| large \| massive` | Relative frame size |
2502
+ | `appearance.eye_color` | `string` | Eye color |
2503
+ | `appearance.hair_color` | `string` | Hair color |
2504
+ | `appearance.skin_color` | `string` | Skin color |
2505
+ | `appearance.complexion` | `string` | Complexion |
2506
+ | `appearance.extra_features` | `string[]` | Extra features |
2493
2507
 
2494
2508
  Author `data.height` as a string of decimal metres (`1.91m` or `1.91 m`) or
2495
2509
  whole feet with optional inches (`6'`, `6'3"`, or `6' 3"`). Inches must be
@@ -1133,8 +1133,8 @@ export class BasePackCompiler {
1133
1133
  // - `aliases:`: it fed the alias index, which the bare
1134
1134
  // `[[Alias]]` form was looked up in; the form is retired, so the
1135
1135
  // list has no reader left. The nested `name.aliases` is a
1136
- // different field and is **not** refused — it is reserved, and
1137
- // deliberately neither read nor validated.
1136
+ // different field: the infobox displays it, and the note name
1137
+ // check validates it.
1138
1138
  //
1139
1139
  // Both are reported and counted — never skipped, which is how a
1140
1140
  // tree naming a package nothing answers to used to compile zero
@@ -55,7 +55,12 @@
55
55
  */
56
56
 
57
57
  import { isAddressTuple } from "./address.mjs";
58
- import { authoredNoteKeys, NOTE_TOP_LEVEL_KEY_SET } from "./note-frontmatter.mjs";
58
+ import {
59
+ authoredNoteKeys,
60
+ CHARACTER_NAME_KEYS,
61
+ NOTE_NAME_KEYS,
62
+ NOTE_TOP_LEVEL_KEY_SET,
63
+ } from "./note-frontmatter.mjs";
59
64
  import { AddressEntries } from "./address-values.mjs";
60
65
  import { addressPositions } from "./note-addresses.mjs";
61
66
  import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
@@ -78,6 +83,7 @@ import { isAddressSegment } from "./address-charset.mjs";
78
83
  // reads is exactly the disagreement to avoid.
79
84
  import { DEFAULT_PARENT } from "./folder-notes.mjs";
80
85
  import {
86
+ BEING_ARCHETYPES,
81
87
  dataFields,
82
88
  declaredTags,
83
89
  subTypeCharsetMessage,
@@ -1173,18 +1179,101 @@ export function lintNote(
1173
1179
  const type = String(fm.type ?? "");
1174
1180
  const raw = () => note.raw ?? "";
1175
1181
  const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
1176
- if (
1177
- type === "being" &&
1178
- Array.isArray(fm.data?.archetypes) &&
1179
- fm.data.archetypes.includes("commoner") &&
1180
- fm.data.archetypes.length > 1
1181
- ) {
1182
- findings.push({
1183
- file: note.file,
1184
- ...at("archetypes", "commoner"),
1185
- severity: "error",
1186
- message: "`data.archetypes` may contain `commoner` only by itself",
1187
- });
1182
+ if (type === "being") {
1183
+ const archetypes = fm.data?.archetypes;
1184
+ if (
1185
+ ["character", "npc"].includes(fm.subType) &&
1186
+ (archetypes == null || (Array.isArray(archetypes) && archetypes.length === 0))
1187
+ ) {
1188
+ findings.push({
1189
+ file: note.file,
1190
+ ...positionOfFrontmatterPath(
1191
+ raw(),
1192
+ archetypes == null ? ["subType"] : ["data", "archetypes"],
1193
+ ),
1194
+ severity: "error",
1195
+ message: "A character or npc requires at least one `data.archetypes` value",
1196
+ });
1197
+ }
1198
+ if (Array.isArray(archetypes)) {
1199
+ archetypes.forEach((value, i) => {
1200
+ if (!Object.hasOwn(BEING_ARCHETYPES, value)) {
1201
+ findings.push({
1202
+ file: note.file,
1203
+ ...positionOfFrontmatterPath(raw(), ["data", "archetypes", i]),
1204
+ severity: "error",
1205
+ message: `Unknown archetype \`${String(value)}\`; use a case-sensitive archetype from the reference`,
1206
+ });
1207
+ }
1208
+ });
1209
+ const commoner = archetypes.indexOf("commoner");
1210
+ if (commoner !== -1 && archetypes.length > 1) {
1211
+ findings.push({
1212
+ file: note.file,
1213
+ ...positionOfFrontmatterPath(raw(), ["data", "archetypes", commoner]),
1214
+ severity: "error",
1215
+ message: "`data.archetypes` may contain `commoner` only by itself",
1216
+ });
1217
+ }
1218
+ }
1219
+ }
1220
+ if (Object.hasOwn(fm, "name")) {
1221
+ const name = fm.name;
1222
+ const nameAt = (path) => positionOfFrontmatterPath(raw(), ["name", ...path], { key: true });
1223
+ if (!name || typeof name !== "object" || Array.isArray(name)) {
1224
+ findings.push({
1225
+ file: note.file,
1226
+ ...positionOfFrontmatterPath(raw(), ["name"], { key: true }),
1227
+ severity: "error",
1228
+ message: "`name` must be a map with a nonempty `full` string",
1229
+ });
1230
+ } else {
1231
+ const allowed = new Set(NOTE_NAME_KEYS);
1232
+ if (type === "being" && fm.subType === "character") {
1233
+ for (const key of CHARACTER_NAME_KEYS) allowed.add(key);
1234
+ }
1235
+ for (const key of Object.keys(name)) {
1236
+ if (!allowed.has(key)) {
1237
+ findings.push({
1238
+ file: note.file,
1239
+ ...nameAt([key]),
1240
+ severity: "error",
1241
+ message: `\`name.${key}\` is not valid for this note`,
1242
+ });
1243
+ }
1244
+ }
1245
+ if (typeof name.full !== "string" || !name.full.trim()) {
1246
+ findings.push({
1247
+ file: note.file,
1248
+ ...(Object.hasOwn(name, "full") ? nameAt(["full"]) : nameAt([])),
1249
+ severity: "error",
1250
+ message: "`name.full` must be a nonempty string",
1251
+ });
1252
+ }
1253
+ if (Object.hasOwn(name, "aliases")) {
1254
+ if (
1255
+ !Array.isArray(name.aliases) ||
1256
+ name.aliases.some((alias) => typeof alias !== "string" || !alias.trim())
1257
+ ) {
1258
+ findings.push({
1259
+ file: note.file,
1260
+ ...nameAt(["aliases"]),
1261
+ severity: "error",
1262
+ message: "`name.aliases` must be a list of nonempty strings",
1263
+ });
1264
+ }
1265
+ }
1266
+ for (const key of CHARACTER_NAME_KEYS) {
1267
+ if (!Object.hasOwn(name, key) || !allowed.has(key)) continue;
1268
+ if (typeof name[key] === "string" && name[key].trim()) continue;
1269
+ findings.push({
1270
+ file: note.file,
1271
+ ...nameAt([key]),
1272
+ severity: "error",
1273
+ message: `\`name.${key}\` must be a nonempty string`,
1274
+ });
1275
+ }
1276
+ }
1188
1277
  }
1189
1278
  /**
1190
1279
  * The in-block keys this note's own type claims for something other than
@@ -706,6 +706,9 @@ function noteBox(
706
706
  const shown = new Set();
707
707
  const name = fm?.name?.full ?? fm?.title;
708
708
  if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
709
+ if (Array.isArray(fm?.name?.aliases) && fm.name.aliases.length) {
710
+ rows.push({ label: "Aliases", kind: "list", value: fm.name.aliases });
711
+ }
709
712
  if (fm?.type === "being" && Object.hasOwn(BEING_SUBTYPE_LABELS, fm.subType)) {
710
713
  rows.push({ label: "Type", kind: "text", value: BEING_SUBTYPE_LABELS[fm.subType] });
711
714
  }
@@ -24,6 +24,12 @@ export const NOTE_TOP_LEVEL_KEYS = Object.freeze([
24
24
  /** Membership test for the top-level vocabulary. */
25
25
  export const NOTE_TOP_LEVEL_KEY_SET = new Set(NOTE_TOP_LEVEL_KEYS);
26
26
 
27
+ /** The name properties every note accepts, in display order. */
28
+ export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases"]);
29
+
30
+ /** Additional name components accepted by an individual character. */
31
+ export const CHARACTER_NAME_KEYS = Object.freeze(["given", "clan"]);
32
+
27
33
  /** Return authored keys from an opening YAML frontmatter map. */
28
34
  export function authoredNoteKeys(markdown) {
29
35
  const source = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
@@ -666,6 +666,25 @@ export function isGmNote(fm) {
666
666
  return hasTag(fm, GM_TAG);
667
667
  }
668
668
 
669
+ /** The case-sensitive archetypes and their author-facing meanings. */
670
+ export const BEING_ARCHETYPES = Object.freeze({
671
+ warrior: "Can hold a line and win a fight.",
672
+ skirmisher: "Fights light — ambush, missile, mobility.",
673
+ infiltrator: "Gets in unseen — locks, stealth, disguise.",
674
+ mage: "Commands arcane practice.",
675
+ cleric: "Commands religious practice and standing.",
676
+ healer: "Treats wounds and illness.",
677
+ scholar: "Reads, researches, and knows things.",
678
+ courtier: "Navigates rank, negotiation, and intrigue.",
679
+ woodsman: "Travels and survives wild country.",
680
+ mariner: "Handles boats and blue water.",
681
+ artisan: "Builds, repairs, and appraises craft work.",
682
+ trader: "Moves goods, values them, and knows markets.",
683
+ commoner: "Fits no more specific archetype; handles ordinary work and daily life.",
684
+ entertainer: "Performs for an audience through acting, music, comedy, or similar arts.",
685
+ guildsperson: "Has professional training, standing, or connections that open doors.",
686
+ });
687
+
669
688
  /**
670
689
  * Every note type this toolchain compiles, and the closed vocabulary it
671
690
  * declares.
@@ -48,13 +48,8 @@
48
48
  * nothing checks unrecognized top-level keys, so left in place it would be
49
49
  * silently ignored rather than reported.
50
50
  *
51
- * **`name.aliases` fed the same index and is nonetheless kept.** It is
52
- * **reserved** — held for a use that does not exist yet — so it is the one
53
- * field here that is neither retired nor read. Nothing consults it: no index,
54
- * no resolver, no lint rule, no emitter. A note carrying one compiles,
55
- * resolves and emits exactly as if it were absent, and `tests/name-aliases-
56
- * reserved.test.ts` pins that equivalence so a future reader cannot be added
57
- * by accident.
51
+ * **`name.aliases` lists alternate names.** The note infobox shows them in
52
+ * authored order. They do not create addresses or change link resolution.
58
53
  *
59
54
  * **What `traits:` did.** It held a being's own description — gender,
60
55
  * species, age, birth date, height, weight, frame and `appearance.*` — at the
@@ -199,10 +194,8 @@ export function assertNoDraftField(fm, { file, absPath } = {}) {
199
194
  * unambiguous folded in every note's `name.full` and so dictated what a note
200
195
  * could be named. The form is retired, so the list has no reader.
201
196
  *
202
- * **`name.aliases` is a different field and is not retired.** It fed the same
203
- * index, but unlike the top-level list it is being kept — reserved, unread,
204
- * and deliberately unmentioned by this message, which would otherwise tell an
205
- * author to delete a field they are allowed to write. See
197
+ * **`name.aliases` is a different field.** It lists alternate names in the
198
+ * infobox and is deliberately unmentioned by this message. See
206
199
  * {@link assertNoAliasesField}.
207
200
  *
208
201
  * @param {string} [file] - The note's path, named in the message. Omit it where
@@ -227,13 +220,9 @@ export function aliasesRetiredMessage(file) {
227
220
  * it reads as "this note claims no other names", a statement about a namespace
228
221
  * that no longer exists.
229
222
  *
230
- * **The nested `name.aliases` is deliberately not refused.** Both spellings fed
231
- * the retired alias index, and both lost their reader with it, but only the
232
- * top-level one is retired: `name.aliases` is **reserved**, held for a use that
233
- * does not exist yet. So it is neither refused nor read — no index consults it,
234
- * no rule validates its contents, nothing derives from it, and nothing emits
235
- * it. It rides in the note as inert data, and a note carrying one compiles,
236
- * resolves and emits exactly as if it were absent.
223
+ * **The nested `name.aliases` is deliberately not refused.** It is the note's
224
+ * ordered alternate names and appears in the note infobox. It does not create
225
+ * addresses or change how authored links resolve.
237
226
  *
238
227
  * That distinction is the reason this checks `Object.hasOwn(fm, "aliases")`
239
228
  * rather than resolving a dotted key: the top-level field is the whole subject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.19.0",
3
+ "version": "22.20.0",
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",
@@ -4,3 +4,7 @@ export function authoredNoteKeys(markdown: any): string[];
4
4
  export const NOTE_TOP_LEVEL_KEYS: readonly string[];
5
5
  /** Membership test for the top-level vocabulary. */
6
6
  export const NOTE_TOP_LEVEL_KEY_SET: Set<string>;
7
+ /** The name properties every note accepts, in display order. */
8
+ export const NOTE_NAME_KEYS: readonly string[];
9
+ /** Additional name components accepted by an individual character. */
10
+ export const CHARACTER_NAME_KEYS: readonly string[];
@@ -244,6 +244,24 @@ export const DECLARED_TAGS: Readonly<{
244
244
  tags: readonly string[];
245
245
  }>;
246
246
  }>;
247
+ /** The case-sensitive archetypes and their author-facing meanings. */
248
+ export const BEING_ARCHETYPES: Readonly<{
249
+ warrior: "Can hold a line and win a fight.";
250
+ skirmisher: "Fights light — ambush, missile, mobility.";
251
+ infiltrator: "Gets in unseen — locks, stealth, disguise.";
252
+ mage: "Commands arcane practice.";
253
+ cleric: "Commands religious practice and standing.";
254
+ healer: "Treats wounds and illness.";
255
+ scholar: "Reads, researches, and knows things.";
256
+ courtier: "Navigates rank, negotiation, and intrigue.";
257
+ woodsman: "Travels and survives wild country.";
258
+ mariner: "Handles boats and blue water.";
259
+ artisan: "Builds, repairs, and appraises craft work.";
260
+ trader: "Moves goods, values them, and knows markets.";
261
+ commoner: "Fits no more specific archetype; handles ordinary work and daily life.";
262
+ entertainer: "Performs for an audience through acting, music, comedy, or similar arts.";
263
+ guildsperson: "Has professional training, standing, or connections that open doors.";
264
+ }>;
247
265
  /**
248
266
  * Every note type this toolchain compiles, and the closed vocabulary it
249
267
  * declares.
@@ -49,10 +49,8 @@ export function assertNoDraftField(fm: object | null | undefined, { file, absPat
49
49
  * unambiguous folded in every note's `name.full` and so dictated what a note
50
50
  * could be named. The form is retired, so the list has no reader.
51
51
  *
52
- * **`name.aliases` is a different field and is not retired.** It fed the same
53
- * index, but unlike the top-level list it is being kept — reserved, unread,
54
- * and deliberately unmentioned by this message, which would otherwise tell an
55
- * author to delete a field they are allowed to write. See
52
+ * **`name.aliases` is a different field.** It lists alternate names in the
53
+ * infobox and is deliberately unmentioned by this message. See
56
54
  * {@link assertNoAliasesField}.
57
55
  *
58
56
  * @param {string} [file] - The note's path, named in the message. Omit it where
@@ -68,13 +66,9 @@ export function aliasesRetiredMessage(file?: string): string;
68
66
  * it reads as "this note claims no other names", a statement about a namespace
69
67
  * that no longer exists.
70
68
  *
71
- * **The nested `name.aliases` is deliberately not refused.** Both spellings fed
72
- * the retired alias index, and both lost their reader with it, but only the
73
- * top-level one is retired: `name.aliases` is **reserved**, held for a use that
74
- * does not exist yet. So it is neither refused nor read — no index consults it,
75
- * no rule validates its contents, nothing derives from it, and nothing emits
76
- * it. It rides in the note as inert data, and a note carrying one compiles,
77
- * resolves and emits exactly as if it were absent.
69
+ * **The nested `name.aliases` is deliberately not refused.** It is the note's
70
+ * ordered alternate names and appears in the note infobox. It does not create
71
+ * addresses or change how authored links resolve.
78
72
  *
79
73
  * That distinction is the reason this checks `Object.hasOwn(fm, "aliases")`
80
74
  * rather than resolving a dotted key: the top-level field is the whole subject,