@heroiclands/package-build 8.1.0 → 10.0.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
package/sohl/actors.mjs CHANGED
@@ -15,12 +15,18 @@
15
15
  * Actors pack compiler — produces JSON pack files for the "actors" Foundry
16
16
  * compendium from markdown `being` notes in the `assets/content/` tree.
17
17
  *
18
- * One content type, named for the Foundry actor it produces. It was two —
19
- * `character` and `creature` which compiled to the same `being` with no
20
- * branch anywhere between them; they were retired in SoHL#1580 and are now
21
- * reported by `assertTypeNotRetired` in `engine/ids.mjs`.
18
+ * One content type today, and the actor subtype it produces is **declared**
19
+ * rather than assumed to be the same word: `sohl/document-subtypes.mjs` maps
20
+ * `being` `Actor` / `being`, and this pass looks it up (#79). It read
21
+ * `ACTOR_VAULT_TYPE = "being"` here and emitted `type: "being"` several hundred
22
+ * lines below, which made the two vocabularies agree by coincidence.
22
23
  *
23
- * Each actor's embedded items are resolved by looking up `<type>:<shortcode>`
24
+ * It was two content types `character` and `creature` — which compiled to the
25
+ * same `being` with no branch anywhere between them; they were retired in
26
+ * SoHL#1580 and are now reported by `assertTypeNotRetired` in
27
+ * `engine/ids.mjs`.
28
+ *
29
+ * Each actor's embedded items are resolved by looking `(type, shortcode)` up
24
30
  * against the generated JSON tree of every Item pack (built in prior items
25
31
  * passes and named by the caller as `itemsSourceDirs`). All of them, because a
26
32
  * repository may group its items into several Item packs (#1566) and a being
@@ -30,6 +36,14 @@
30
36
  * resolved by `(type, shortcode)` and deep-merged with the entry's other
31
37
  * properties. `sohl.skills` is ignored.
32
38
  *
39
+ * **Those references are in the note vocabulary and the addresses are in the
40
+ * document's**, and the difference is stated rather than assumed away (#140):
41
+ * {@link itemAddress} keys a predefined item by the subtype its compiled
42
+ * document carries, and {@link Actors#embeddedSubtype} translates each
43
+ * authored reference forward through the system's map before the lookup. A
44
+ * reference that then resolves to nothing is a finding naming the note and the
45
+ * reference — never an item quietly missing from the compiled actor.
46
+ *
33
47
  * Not a standalone script — exports the `Actors` compiler class, imported and
34
48
  * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`). Must run
35
49
  * after the items pass, since it reads the items pack's generated JSON tree.
@@ -50,27 +64,81 @@ import {
50
64
  resolveName,
51
65
  resolveImg,
52
66
  defaultStats,
53
- withArchetypeFlag,
67
+ systemArchetype,
54
68
  md,
55
69
  } from "../engine/helpers.mjs";
56
70
  import { emitDiagnostic } from "../engine/diagnostics.mjs";
57
71
  import { openingMasteryLevel } from "./skill-base.mjs";
58
72
  import { BasePackCompiler } from "../engine/base-compiler.mjs";
59
73
  import { contentPackage } from "../engine/content-package.mjs";
74
+ // Which Foundry Actor subtype a note's `type` compiles into, and which note
75
+ // types are actors at all. Looked up in the system's declared map, never
76
+ // inferred from the type itself (#79).
77
+ import {
78
+ documentSubtype,
79
+ mapsNoteType,
80
+ noteTypesFor,
81
+ referencedSubtype,
82
+ } from "../engine/document-subtypes.mjs";
83
+ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
84
+ import { locateFrontmatterKey } from "../engine/retired-fields.mjs";
85
+ // The note-level `sohl:` block: `sohl.system` onto the document's `system`
86
+ // verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
87
+ // shared top-level forms for this system alone (#58).
88
+ import { blockProperty, mergeSystemData } from "../engine/system-block.mjs";
60
89
 
61
90
  /**
62
- * The content type this pass claims. A note's `type` names the Foundry document
63
- * it compiles into, exactly as every other content type does.
91
+ * The system this pass compiles for the block its notes write.
92
+ *
93
+ * Read from the map rather than spelled here, so the block name and the subtype
94
+ * map are one statement (#58/#79).
95
+ *
96
+ * @type {string}
64
97
  */
65
- const ACTOR_VAULT_TYPE = "being";
98
+ const SYSTEM = SOHL_DOCUMENT_SUBTYPES.block;
66
99
 
67
- // Default art per actor type, applied when frontmatter supplies no `img` /
68
- // `portrait`. Beings default to the generic person icon; other actor types add
69
- // their own default here as they gain a builder.
100
+ /**
101
+ * The note types this pass claims every one the system's map sends to an
102
+ * `Actor`.
103
+ *
104
+ * Read from the map rather than restated as a constant. It *was* a constant:
105
+ * `ACTOR_VAULT_TYPE = "being"` sat here and `type: "being"` was emitted several
106
+ * hundred lines below, with nothing relating them, so a change to either
107
+ * followed the other only by coincidence (#79).
108
+ *
109
+ * @type {readonly string[]}
110
+ */
111
+ const ACTOR_NOTE_TYPES = Object.freeze(noteTypesFor(SOHL_DOCUMENT_SUBTYPES, "Actor"));
112
+
113
+ // Default art per actor **subtype**, applied when frontmatter supplies no `img`
114
+ // / `portrait`. Beings default to the generic person icon; another subtype adds
115
+ // its own entry here as the map gains a row for it.
70
116
  const DEFAULT_IMG = {
71
117
  being: "systems/sohl/assets/icons/game-icons/delapouite/person.svg",
72
118
  };
73
119
 
120
+ /**
121
+ * The default art for an actor subtype.
122
+ *
123
+ * Fail-fast, for the reason {@link itemArt} is: a subtype with no art would
124
+ * otherwise ship a document with no image and nothing said about it.
125
+ *
126
+ * @param {string} subType - The Foundry actor subtype.
127
+ * @returns {string} The default image path.
128
+ * @throws {Error} When this map pairs no art with the subtype.
129
+ */
130
+ function defaultActorImg(subType) {
131
+ const img = /** @type {Record<string, string|undefined>} */ (DEFAULT_IMG)[subType];
132
+ if (!img) {
133
+ throw new Error(
134
+ `No default art for actor subtype "${subType}" — add an entry to ` +
135
+ `\`DEFAULT_IMG\` in sohl/actors.mjs, beside the map row that ` +
136
+ `introduced the subtype.`,
137
+ );
138
+ }
139
+ return img;
140
+ }
141
+
74
142
  /**
75
143
  * Strip compendium-only fields from a predefined item before embedding it
76
144
  * inside an actor's `items[]`. These fields belong on a top-level
@@ -151,17 +219,35 @@ function extractBodyAndMovement(fm) {
151
219
  return {
152
220
  body: normalizeBody(sohlField(fm, "body", {})),
153
221
  currentMoveMedium: String(sohlField(fm, "currentMoveMedium", "none")),
154
- movementProfiles: normalizeMovementProfiles(
155
- sohlField(fm, "movementProfiles", []),
156
- ),
222
+ movementProfiles: normalizeMovementProfiles(sohlField(fm, "movementProfiles", [])),
157
223
  };
158
224
  }
159
225
 
226
+ /**
227
+ * The key one predefined item is held under, and every place that spells it.
228
+ *
229
+ * **The vocabulary is the document's, not the note's** — `subType` is the
230
+ * Foundry Item subtype the compiled document carries, which is the only thing a
231
+ * compiled pack (or an extracted dependency catalogue) records about what an
232
+ * item *is*. A being's frontmatter addresses the same item in the *note*
233
+ * vocabulary, so a reference is translated forward through the system's map
234
+ * before it reaches this function; see {@link Actors#embeddedSubtype} for why
235
+ * the translation goes that way and not the other (#140).
236
+ *
237
+ * @param {string} subType - The Foundry Item subtype.
238
+ * @param {string} shortcode - The item's `system.shortcode`.
239
+ * @returns {string} The address, `subType:shortcode`.
240
+ */
241
+ function itemAddress(subType, shortcode) {
242
+ return `${subType}:${shortcode}`;
243
+ }
244
+
160
245
  /**
161
246
  * Load every JSON file under each of `itemsSourceDirs`, returning one Map keyed
162
- * by `${type}:${system.shortcode}`. Folder docs and entries without a
163
- * shortcode are skipped. The `_key` field is stripped from each entry —
164
- * it is not part of the item data model.
247
+ * by {@link itemAddress} the compiled document's **subtype** and its
248
+ * `system.shortcode`. Folder docs and entries without a shortcode are skipped.
249
+ * The `_key` field is stripped from each entry — it is not part of the item
250
+ * data model.
165
251
  *
166
252
  * The directories are read as one address space, because a being names an item
167
253
  * by `(type, shortcode)` and never by the pack it happens to ship in. Two local
@@ -215,7 +301,7 @@ function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
215
301
  }
216
302
  const shortcode = doc?.system?.shortcode;
217
303
  if (!doc?.type || !shortcode) continue;
218
- const address = `${doc.type}:${shortcode}`;
304
+ const address = itemAddress(doc.type, shortcode);
219
305
  const owner = source.get(address);
220
306
  if (owner && owner !== itemsSourceDir) {
221
307
  throw new Error(
@@ -249,7 +335,7 @@ function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
249
335
  }
250
336
  const shortcode = doc?.system?.shortcode;
251
337
  if (!doc?.type || !shortcode) continue;
252
- const address = `${doc.type}:${shortcode}`;
338
+ const address = itemAddress(doc.type, shortcode);
253
339
  if (map.has(address)) {
254
340
  // Deliberate: this repository defines it, so its version wins.
255
341
  if (source.has(address)) shadowed.push(address);
@@ -290,8 +376,7 @@ function extractAnchorSection(body, anchorId) {
290
376
  if (capturing) captured.push(line);
291
377
  continue;
292
378
  }
293
- const h1Match =
294
- !inCodeBlock ? line.match(/^\s*#\s+(.+?)\s*#*\s*$/) : null;
379
+ const h1Match = !inCodeBlock ? line.match(/^\s*#\s+(.+?)\s*#*\s*$/) : null;
295
380
  if (h1Match) {
296
381
  const anchor = h1Match[1].match(/\{#([^}]+)\}\s*$/);
297
382
  const id = anchor?.[1]?.trim().toLowerCase() || null;
@@ -367,14 +452,34 @@ export class Actors extends BasePackCompiler {
367
452
  });
368
453
  }
369
454
 
455
+ /**
456
+ * The note-type → document-subtype map this pass compiles against.
457
+ *
458
+ * Stated by the class rather than reached for through the module import, so
459
+ * every subtype decision the pass makes — the actor's own, and each
460
+ * embedded item reference's — reads one declaration that a subclass
461
+ * compiling for another system can replace. That is also what lets the
462
+ * non-identity behaviour be exercised without introducing a non-identity
463
+ * row into SoHL's own map, which is #78's job and moves compiled bytes.
464
+ *
465
+ * @type {import("../engine/document-subtypes.mjs").DocumentSubtypeMap}
466
+ */
467
+ static documentSubtypes = SOHL_DOCUMENT_SUBTYPES;
468
+
370
469
  /**
371
470
  * @param {object} fm - The note's frontmatter.
372
- * @returns {boolean} True for a `being` note.
471
+ * @returns {boolean} True for a note type the system maps onto an `Actor`.
373
472
  */
374
473
  selects(fm) {
375
- return fm.type === ACTOR_VAULT_TYPE;
474
+ return mapsNoteType(this.constructor.documentSubtypes, fm.type, "Actor");
376
475
  }
377
476
 
477
+ /**
478
+ * An Actor **is** a system's data, so this pack takes only notes carrying
479
+ * this system's block (#58).
480
+ */
481
+ static requiresSystemBlock = true;
482
+
378
483
  /**
379
484
  * The predefined items each being's embedded items resolve against, loaded
380
485
  * before the walk from the items passes' output.
@@ -383,13 +488,8 @@ export class Actors extends BasePackCompiler {
383
488
  */
384
489
  async prepare() {
385
490
  await super.prepare();
386
- this.itemsMap = loadItemsMap(
387
- this.itemsSourceDirs,
388
- this.foreignSourceDirs,
389
- );
390
- log.info(
391
- `Loaded ${this.itemsMap.size} predefined items for actor resolution`,
392
- );
491
+ this.itemsMap = loadItemsMap(this.itemsSourceDirs, this.foreignSourceDirs);
492
+ log.info(`Loaded ${this.itemsMap.size} predefined items for actor resolution`);
393
493
  }
394
494
 
395
495
  /**
@@ -408,53 +508,112 @@ export class Actors extends BasePackCompiler {
408
508
  reportDetail(stats) {
409
509
  log.debug(
410
510
  `Skipped ${stats.skippedOther} non-actor file(s) ` +
411
- `(not ${ACTOR_VAULT_TYPE}, package:${contentPackage()})`,
511
+ `(not ${ACTOR_NOTE_TYPES.join("/")}, package:${contentPackage()})`,
412
512
  );
413
513
  }
414
514
 
515
+ /**
516
+ * The Foundry Item subtype an embedded reference's `type` addresses.
517
+ *
518
+ * **The reference is in the note vocabulary; the address is in the
519
+ * document's** (#140). A being writes `(type, shortcode)` with the type an
520
+ * author authors, while {@link itemAddress} keys the predefined items by
521
+ * the subtype each compiled document carries — so exactly one of the two
522
+ * sides has to translate, and it is this one. The system's map is a
523
+ * function from note type to subtype by construction; the reverse is not,
524
+ * and a compiled document records nothing about the note that produced it,
525
+ * so there is no honest way to key the addresses the other way round.
526
+ *
527
+ * The two vocabularies are the same string in every SoHL row today, which
528
+ * is why looking a reference up verbatim worked. The first non-identity row
529
+ * (#78: `armor` → `armorgear`) ends that, and a reference resolving to
530
+ * nothing must be a finding rather than an item quietly missing from the
531
+ * compiled actor.
532
+ *
533
+ * @param {string} type - The type the reference names.
534
+ * @returns {import("../engine/document-subtypes.mjs").ReferencedSubtype}
535
+ * The subtype, or why the reference names none.
536
+ */
537
+ embeddedSubtype(type) {
538
+ return referencedSubtype(this.constructor.documentSubtypes, type, "Item");
539
+ }
540
+
415
541
  /**
416
542
  * Resolve one embedded item from a `(type, shortcode?, overlay)`
417
543
  * descriptor. If `shortcode` is given, the predefined item is fetched
418
544
  * from `itemsMap` and the overlay deep-merged on top. If absent, the
419
545
  * descriptor must carry enough fields to stand alone. The embedded
420
546
  * item's `_id` is regenerated deterministically from
421
- * `(actorId, type, shortcode, indexKey)` so re-exports are stable.
547
+ * `(actorId, subType, shortcode, indexKey)` so re-exports are stable
548
+ * from the **document subtype**, so that renaming a note type (#78) leaves
549
+ * every embedded id exactly where it was.
422
550
  * Returns null if the descriptor cannot be resolved.
551
+ *
552
+ * @param {Map<string, object>} itemsMap - The predefined items, by address.
553
+ * @param {string} actorId - The owning actor's id, seeding embedded ids.
554
+ * @param {string} type - The **note** type the reference names.
555
+ * @param {string|null} shortcode - The referenced item's shortcode, or
556
+ * `null` for a stand-alone entry.
557
+ * @param {object} [overlay] - The entry's remaining properties.
558
+ * @param {string} indexKey - Distinguishes two references to one item.
559
+ * @param {string} ctx - Diagnostic context (the actor's label).
560
+ * @param {object} [at] - Where to locate a finding.
561
+ * @param {string} [at.fmKey] - The frontmatter key the reference sits
562
+ * under, so an unresolved one is reported at the reference rather than
563
+ * at the note.
564
+ * @returns {object|null} The embedded item, or null when it resolved to
565
+ * nothing — always with a finding emitted.
423
566
  */
424
- resolveEmbedded(
425
- itemsMap,
426
- actorId,
427
- type,
428
- shortcode,
429
- overlay,
430
- indexKey,
431
- ctx,
432
- ) {
567
+ resolveEmbedded(itemsMap, actorId, type, shortcode, overlay, indexKey, ctx, { fmKey } = {}) {
568
+ // Where a finding about this reference points. The value locates the
569
+ // exact entry in a list; the key is the fallback when it cannot be
570
+ // found, which still beats naming the note alone.
571
+ const where = () =>
572
+ locateFrontmatterKey(this.currentNote?.absPath, fmKey ?? "items", shortcode || type);
573
+
574
+ const { subType, problem } = this.embeddedSubtype(type);
575
+ if (problem) {
576
+ this.noteError(`${ctx}: ${indexKey}: ${problem}`, where());
577
+ this.errorCount++;
578
+ return null;
579
+ }
580
+ const address = itemAddress(/** @type {string} */ (subType), shortcode ?? "");
581
+
433
582
  let base = null;
434
583
  if (shortcode) {
435
- base = itemsMap.get(`${type}:${shortcode}`);
584
+ base = itemsMap.get(address);
436
585
  if (!base) {
586
+ // Both vocabularies where they differ, so an author sees why an
587
+ // address they wrote did not land where they expected.
588
+ const translated =
589
+ subType === type ? "" : (
590
+ ` (looked up as "${address}", the ` +
591
+ `${this.constructor.documentSubtypes.system} Item subtype a ` +
592
+ `"${type}" note compiles into)`
593
+ );
437
594
  this.noteError(
438
- `${ctx}: no predefined item for "${type}:${shortcode}"`,
595
+ `${ctx}: no predefined item for "${type}:${shortcode}"${translated}`,
596
+ where(),
439
597
  );
440
598
  this.errorCount++;
441
599
  return null;
442
600
  }
443
601
  base = stripCompendiumFields(base);
444
602
  } else if (overlay && overlay.name && overlay.system) {
445
- base = { type, name: overlay.name, system: {} };
603
+ base = { type: subType, name: overlay.name, system: {} };
446
604
  } else {
447
605
  this.noteError(
448
606
  `${ctx}: embedded item missing shortcode and not enough fields to stand alone`,
607
+ where(),
449
608
  );
450
609
  this.errorCount++;
451
610
  return null;
452
611
  }
453
612
  const merged = overlay ? deepMerge(base, overlay) : base;
454
- merged.type = type;
613
+ merged.type = subType;
455
614
  merged._id = makeId(
456
615
  actorId,
457
- `${type}:${shortcode || merged.name}:${indexKey}`,
616
+ `${itemAddress(/** @type {string} */ (subType), shortcode || merged.name)}:${indexKey}`,
458
617
  );
459
618
  // Foundry's pack compiler flattens the document hierarchy into LevelDB,
460
619
  // storing each embedded document under its own `_key`. Embedded items
@@ -490,6 +649,7 @@ export class Actors extends BasePackCompiler {
490
649
  overlay,
491
650
  `attr:${shortcode}`,
492
651
  ctx,
652
+ { fmKey: "attributes" },
493
653
  );
494
654
  if (embedded) items.push(embedded);
495
655
  }
@@ -499,9 +659,7 @@ export class Actors extends BasePackCompiler {
499
659
  if (Array.isArray(sohlItems)) {
500
660
  sohlItems.forEach((entry, index) => {
501
661
  if (!entry || typeof entry !== "object") {
502
- this.noteError(
503
- `${ctx}: sohl.items[${index}] is not an object`,
504
- );
662
+ this.noteError(`${ctx}: sohl.items[${index}] is not an object`);
505
663
  this.errorCount++;
506
664
  return;
507
665
  }
@@ -519,6 +677,7 @@ export class Actors extends BasePackCompiler {
519
677
  rest,
520
678
  `items:${index}`,
521
679
  ctx,
680
+ { fmKey: "items" },
522
681
  );
523
682
  if (embedded) items.push(embedded);
524
683
  });
@@ -556,10 +715,7 @@ export class Actors extends BasePackCompiler {
556
715
  */
557
716
  openUnopenedSkills(items, ctx) {
558
717
  const skills = items.filter(
559
- (item) =>
560
- item.type === "skill" &&
561
- item.system &&
562
- item.system.masteryLevelBase == null,
718
+ (item) => item.type === "skill" && item.system && item.system.masteryLevelBase == null,
563
719
  );
564
720
  if (!skills.length) return;
565
721
 
@@ -585,6 +741,14 @@ export class Actors extends BasePackCompiler {
585
741
  const name = resolveName(fm);
586
742
  const id = fm.id;
587
743
  const ctx = `actor "${name}"`;
744
+ // The document's own subtype, and the art that goes with it. Both are
745
+ // looked up from the note's `type` rather than spelled here (#79).
746
+ const subType = /** @type {string} */ (
747
+ documentSubtype(this.constructor.documentSubtypes, fm.type, fm, {
748
+ absPath: this.currentNote?.absPath,
749
+ })
750
+ );
751
+ const defaultImg = defaultActorImg(subType);
588
752
 
589
753
  const items = this.buildEmbeddedItems(itemsMap, id, fm, ctx);
590
754
 
@@ -593,10 +757,13 @@ export class Actors extends BasePackCompiler {
593
757
 
594
758
  const system = {
595
759
  // The frontmatter shortcode is the actor's stable `(type, shortcode)`
596
- // key — and, for a `docArchetype`-flagged being, its archetype
760
+ // key — and, for a being that is an archetype, its archetype
597
761
  // identity (the dedup/override key of the Create-dialog picker, #604).
598
762
  shortcode: fm.shortcode || "",
599
- portrait: resolveImg(fm.portrait) || DEFAULT_IMG.being,
763
+ // Required nullable number: a priority, or `null` for a being that
764
+ // is not an archetype (#126 / archetype contract #604).
765
+ archetype: systemArchetype(fm, ctx),
766
+ portrait: resolveImg(blockProperty(fm, SYSTEM, "portrait")) || defaultImg,
600
767
  appearance: renderSection(body || "", "appearance"),
601
768
  dossier: renderSection(body || "", "dossier"),
602
769
  };
@@ -621,19 +788,34 @@ export class Actors extends BasePackCompiler {
621
788
  }
622
789
 
623
790
  // Being-only combat grouping (mirrors `system.defaultCombatGroup`).
624
- const defaultCombatGroup = sohlField(
625
- fm,
626
- "defaultCombatGroup",
627
- undefined,
628
- );
791
+ const defaultCombatGroup = sohlField(fm, "defaultCombatGroup", undefined);
629
792
  if (defaultCombatGroup !== undefined) {
630
793
  system.defaultCombatGroup = defaultCombatGroup;
631
794
  }
632
795
 
796
+ // Whatever the note authors under `sohl.system`, at the DataModel's own
797
+ // paths (#58). This pass has no field declaration, so it claims
798
+ // nothing: every authored path is the author's, and the fields above
799
+ // are what a note that authors none still gets.
800
+ mergeSystemData(system, fm, { block: SYSTEM });
801
+ this.reportUndeclaredSystemData(fm, SYSTEM, "Actor", subType);
802
+ // And what this pass wrote itself. There is no field declaration for a
803
+ // being at all, so *every* key here is a compiler emission — including
804
+ // `archetype` (#126), which nothing compared until #155.
805
+ this.reportEmittedSystemData(system, {
806
+ fm,
807
+ block: SYSTEM,
808
+ documentType: "Actor",
809
+ subType,
810
+ type: fm.type,
811
+ });
812
+
813
+ const effects = blockProperty(fm, SYSTEM, "effects");
814
+
633
815
  return {
634
816
  name,
635
- type: "being",
636
- img: resolveImg(fm.img) || DEFAULT_IMG.being,
817
+ type: subType,
818
+ img: resolveImg(blockProperty(fm, SYSTEM, "img")) || defaultImg,
637
819
  _id: id,
638
820
  system,
639
821
  items,
@@ -641,19 +823,20 @@ export class Actors extends BasePackCompiler {
641
823
  name,
642
824
  displayName: 0,
643
825
  actorLink: false,
644
- texture: { src: resolveImg(fm.img) || DEFAULT_IMG.being },
826
+ texture: { src: resolveImg(blockProperty(fm, SYSTEM, "img")) || defaultImg },
645
827
  width: 1,
646
828
  height: 1,
647
829
  sight: { enabled: false },
648
830
  detectionModes: [],
649
831
  },
650
- effects: [],
832
+ effects: Array.isArray(effects) ? [...effects] : [],
651
833
  folder,
652
834
  sort: 0,
653
835
  ownership: { default: 0 },
654
- // `sohl.archetype` (required nullable number) drives
655
- // `flags.sohl.docArchetype` (#640 / archetype contract #604).
656
- flags: withArchetypeFlag(fm, fm.flags, ctx),
836
+ // Whatever the note authors, and nothing else. `archetype` used to
837
+ // be spliced in here as `flags.sohl.docArchetype`; it is a schema
838
+ // field now and sits in `system` (#126).
839
+ flags: blockProperty(fm, SYSTEM, "flags", {}),
657
840
  _stats: this.stats,
658
841
  _key: `!actors!${id}`,
659
842
  };
@@ -108,8 +108,7 @@ export function deriveBeingInfo(sohl, index) {
108
108
  const items = Array.isArray(out.items) ? out.items : [];
109
109
  if (items.length === 0) return out;
110
110
 
111
- const lookup = (type, shortcode) =>
112
- shortcode ? index.get(`${type}:${shortcode}`) : undefined;
111
+ const lookup = (type, shortcode) => (shortcode ? index.get(`${type}:${shortcode}`) : undefined);
113
112
 
114
113
  /** An item's display name: its own, then the index's, then its shortcode. */
115
114
  const displayName = (it, ref, shortcode) =>
@@ -135,8 +134,7 @@ export function deriveBeingInfo(sohl, index) {
135
134
  if (!isMap(it)) continue;
136
135
  const key = GEAR_TYPE_TO_KEY[it.type];
137
136
  if (!key) continue;
138
- const shortcode =
139
- typeof it.shortcode === "string" ? it.shortcode : undefined;
137
+ const shortcode = typeof it.shortcode === "string" ? it.shortcode : undefined;
140
138
  const ref = lookup(it.type, shortcode);
141
139
  const name = displayName(it, ref, shortcode);
142
140
  if (!name) continue;
@@ -153,8 +151,7 @@ export function deriveBeingInfo(sohl, index) {
153
151
  const talents = [];
154
152
  for (const it of items) {
155
153
  if (!isMap(it) || it.type !== "mysticalability") continue;
156
- const shortcode =
157
- typeof it.shortcode === "string" ? it.shortcode : undefined;
154
+ const shortcode = typeof it.shortcode === "string" ? it.shortcode : undefined;
158
155
  const ref = lookup("mysticalability", shortcode);
159
156
  // No shortcode fallback here: an ability with neither an inline name
160
157
  // nor an index entry has nothing to show, and a row reading like a
@@ -0,0 +1,82 @@
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
+ * **SoHL's note-type → document-subtype map** — which Foundry document, and
16
+ * which subtype of it, a note of each content type compiles into (#79).
17
+ *
18
+ * The mechanism is `engine/document-subtypes.mjs`; what a *game system* does
19
+ * with it is here, which is the `engine/` ÷ `sohl/` line this package draws
20
+ * everywhere else (#36). A second system declares its own map in its own half
21
+ * and shares nothing but the mechanism.
22
+ *
23
+ * **Every row is written out, identity rows included.** `skill → skill` looks
24
+ * like a row that could be derived from the item registry's keys, and deriving
25
+ * it is precisely the defect this map exists to remove: the note vocabulary
26
+ * and the document vocabulary would once again be the same identifier because
27
+ * one list generated the other, and a rename on either side would follow
28
+ * silently. Sixteen lines of data are the price of the two vocabularies being
29
+ * separately stated.
30
+ *
31
+ * **Today every row is the identity, and that is a fact about SoHL rather than
32
+ * a rule.** The renames the format calls for (`armorgear` → `armor`, and its
33
+ * three siblings) are #78, deliberately deferred: they cost 30,741 embedded
34
+ * `(type, shortcode)` references across four content repositories, and until
35
+ * they land the compiled packs must not move by a byte. When one does land it
36
+ * changes one row here and the notes that address it — a data change, not a
37
+ * mechanism change, which is the whole point of having the map first.
38
+ *
39
+ * **What this map is not.** It says which document a note becomes, never what
40
+ * that document contains: the `system` block comes from the item registry's
41
+ * builders (`item-builders.mjs`), and an item's own `system.subType` field —
42
+ * a skill's `physical` / `combattechnique`, an affliction's kind — is a
43
+ * *data-model field*, unrelated to the document subtype named here.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import { defineDocumentSubtypes } from "../engine/document-subtypes.mjs";
49
+
50
+ /**
51
+ * Every content type SoHL compiles into a Foundry document, and what it
52
+ * becomes.
53
+ *
54
+ * The Item rows are the thirteen types the item registry declares; the one
55
+ * Actor row is `being`, which was two types (`character` and `creature`)
56
+ * compiling to the same actor until they were retired in SoHL#1580. Types this
57
+ * map does not name — `doc`, `macro`, the three map types — compile into
58
+ * documents that carry no system subtype at all, so they have no row and never
59
+ * needed one.
60
+ *
61
+ * @type {import("../engine/document-subtypes.mjs").DocumentSubtypeMap}
62
+ */
63
+ export const SOHL_DOCUMENT_SUBTYPES = defineDocumentSubtypes({
64
+ system: "sohl",
65
+ types: {
66
+ affiliation: { document: "Item", subType: "affiliation" },
67
+ affliction: { document: "Item", subType: "affliction" },
68
+ armorgear: { document: "Item", subType: "armorgear" },
69
+ attribute: { document: "Item", subType: "attribute" },
70
+ concoctiongear: { document: "Item", subType: "concoctiongear" },
71
+ containergear: { document: "Item", subType: "containergear" },
72
+ miscgear: { document: "Item", subType: "miscgear" },
73
+ mystery: { document: "Item", subType: "mystery" },
74
+ mysticalability: { document: "Item", subType: "mysticalability" },
75
+ projectilegear: { document: "Item", subType: "projectilegear" },
76
+ skill: { document: "Item", subType: "skill" },
77
+ trauma: { document: "Item", subType: "trauma" },
78
+ weapongear: { document: "Item", subType: "weapongear" },
79
+
80
+ being: { document: "Actor", subType: "being" },
81
+ },
82
+ });
package/sohl/index.mjs CHANGED
@@ -33,6 +33,9 @@
33
33
  /** The item-type registry: every type that compiles into an Item, and its builder. */
34
34
  export * as itemBuilders from "./item-builders.mjs";
35
35
 
36
+ /** This system's note-type → document-subtype map, identity rows included (#79). */
37
+ export * as documentSubtypes from "./document-subtypes.mjs";
38
+
36
39
  /** The Item compiler. */
37
40
  export * as items from "./items.mjs";
38
41
 
@@ -47,9 +50,4 @@ export * as kbPasses from "./kb-passes.mjs";
47
50
  // compilers arrived (#1510).
48
51
  export { DEFAULT_ITEM_ART, defaultItemArt } from "./default-item-art.mjs";
49
52
  export { AFFILIATION_STANDINGS } from "./affiliation-standings.mjs";
50
- export {
51
- BEING_TYPE,
52
- GEAR_TYPE_TO_KEY,
53
- deriveBeingInfo,
54
- isBeing,
55
- } from "./being-info.mjs";
53
+ export { BEING_TYPE, GEAR_TYPE_TO_KEY, deriveBeingInfo, isBeing } from "./being-info.mjs";
@@ -108,7 +108,5 @@ function entryFor(type) {
108
108
  * @type {Readonly<Record<string, Readonly<{system: (fm: object) => object, img: string, fields: readonly object[]}>>>}
109
109
  */
110
110
  export const ITEM_BUILDERS = Object.freeze(
111
- Object.fromEntries(
112
- Object.keys(ITEM_FIELDS).map((type) => [type, entryFor(type)]),
113
- ),
111
+ Object.fromEntries(Object.keys(ITEM_FIELDS).map((type) => [type, entryFor(type)])),
114
112
  );