@heroiclands/package-build 17.1.0 → 18.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -0,0 +1,586 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **The Actor pass, for any system** — the parts of compiling a note into a
16
+ * Foundry Actor that belong to the note format rather than to a game system
17
+ * (#139).
18
+ *
19
+ * The whole of it lived in `sohl/actors.mjs`, where the system-specific facts
20
+ * were already funnelled through one map (`static documentSubtypes`, added by
21
+ * #79) and one block constant. A second system needs everything except the
22
+ * shape of the `system` block itself, so that everything moved here and each
23
+ * half declares what differs:
24
+ *
25
+ * | stated by the subclass | what it decides |
26
+ * | --- | --- |
27
+ * | `static documentSubtypes` | the block its notes write, the types it claims, the subtype each becomes |
28
+ * | `buildEntry()` | the document its system's data model actually wants |
29
+ *
30
+ * What is shared is the machinery an actor of *any* system needs:
31
+ *
32
+ * - **The predefined-item catalogue.** Every Item pack's compiled output, read
33
+ * as one address space keyed by `subType:shortcode`, with a dependency
34
+ * catalogue behind it as a fallback rather than a peer.
35
+ * - **Reference translation.** A being addresses its embedded items in the
36
+ * *note* vocabulary and the catalogue is keyed in the *document's*, so
37
+ * {@link SystemActorCompiler#embeddedSubtype} translates each reference
38
+ * forward through this system's map before the lookup (#140).
39
+ * - **Embedding.** Merging a note's overlay onto a catalogue entry, deriving a
40
+ * stable embedded id from the owning actor and the address, and re-keying the
41
+ * embedded document and its effects for the LevelDB flattening.
42
+ * - **Anchored prose.** `{#appearance}` / `{#dossier}` are pulled out by
43
+ * {@link module:engine/anchored-sections}, re-exported here for the passes
44
+ * that reach it through this module; *which document field* each lands in is
45
+ * the system's, and is decided in `buildEntry`.
46
+ *
47
+ * @module
48
+ */
49
+
50
+ import fs from "fs";
51
+ import path from "path";
52
+ import log from "loglevel";
53
+
54
+ import { makeId } from "./helpers.mjs";
55
+ import { emitDiagnostic } from "./diagnostics.mjs";
56
+ // The `{#appearance}` / `{#dossier}` convention is the note format's, so the
57
+ // extraction is shared; which field a section lands in stays the system's.
58
+ export { extractAnchorSection, renderSection } from "./anchored-sections.mjs";
59
+ import { BasePackCompiler } from "./base-compiler.mjs";
60
+ import { contentPackage } from "./content-package.mjs";
61
+ // Which Foundry Actor subtype a note's `type` compiles into, and which note
62
+ // types are actors at all. Looked up in the system's declared map, never
63
+ // inferred from the type itself (#79).
64
+ import { mapsNoteType, noteTypesFor, referencedSubtype } from "./document-subtypes.mjs";
65
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
66
+
67
+ /**
68
+ * Strip compendium-only fields from a predefined item before embedding it
69
+ * inside an actor's `items[]`. These fields belong on a top-level
70
+ * compendium document, not on an embedded one.
71
+ *
72
+ * @param {object} item - The catalogue entry.
73
+ * @returns {object} The entry, without its compendium-only fields.
74
+ */
75
+ export function stripCompendiumFields(item) {
76
+ // eslint-disable-next-line no-unused-vars
77
+ const { _key, _stats, ownership, folder, ...rest } = item;
78
+ return rest;
79
+ }
80
+
81
+ /**
82
+ * Whether a value is a plain object, for {@link deepMerge}.
83
+ *
84
+ * @param {unknown} v - The value.
85
+ * @returns {boolean} True for a plain object.
86
+ */
87
+ export function isPlainObject(v) {
88
+ return (
89
+ v !== null &&
90
+ typeof v === "object" &&
91
+ !Array.isArray(v) &&
92
+ Object.getPrototypeOf(v) === Object.prototype
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Recursively merge `overlay` onto `base`. Plain objects merge key-by-key;
98
+ * everything else (arrays, primitives, null) replaces. Inputs are not
99
+ * mutated.
100
+ *
101
+ * @param {any} base - The catalogue entry.
102
+ * @param {any} overlay - The note's own values.
103
+ * @returns {any} The merged value.
104
+ */
105
+ export function deepMerge(base, overlay) {
106
+ if (overlay === undefined) return base;
107
+ if (!isPlainObject(base) || !isPlainObject(overlay)) {
108
+ return overlay;
109
+ }
110
+ const out = { ...base };
111
+ for (const [k, v] of Object.entries(overlay)) {
112
+ out[k] = k in base ? deepMerge(base[k], v) : v;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * The key one predefined item is held under, and every place that spells it.
119
+ *
120
+ * **The vocabulary is the document's, not the note's** — `subType` is the
121
+ * Foundry Item subtype the compiled document carries, which is the only thing a
122
+ * compiled pack (or an extracted dependency catalogue) records about what an
123
+ * item *is*. An actor's frontmatter addresses the same item in the *note*
124
+ * vocabulary, so a reference is translated forward through the system's map
125
+ * before it reaches this function; see
126
+ * {@link SystemActorCompiler#embeddedSubtype} for why the translation goes that
127
+ * way and not the other (#140).
128
+ *
129
+ * @param {string} subType - The Foundry Item subtype.
130
+ * @param {string} shortcode - The item's `system.shortcode`.
131
+ * @returns {string} The address, `subType:shortcode`.
132
+ */
133
+ export function itemAddress(subType, shortcode) {
134
+ return `${subType}:${shortcode}`;
135
+ }
136
+
137
+ /**
138
+ * What identifies one embedded item on its actor.
139
+ *
140
+ * **Its own `system.shortcode`** — not the entry's top-level `shortcode`, which
141
+ * merely *selects* the catalogue template the entry is written from and is
142
+ * never written to the document. Two daggers may share a selector; they are two
143
+ * embodiments and each must declare its own.
144
+ *
145
+ * The name is a last resort, for a **stand-alone** entry that names no template
146
+ * and states no shortcode. It is a poor identity — presentation, and free to be
147
+ * localized or to diverge — so it is not a fallback the compiler is content
148
+ * with: two entries reducing to one identity is refused either way, and the
149
+ * message says to state a `system.shortcode`.
150
+ *
151
+ * @param {object} item - The merged embedded item.
152
+ * @returns {string} The identity, for {@link embeddedItemId}.
153
+ */
154
+ export function embeddedIdentity(item) {
155
+ const own = item?.system?.shortcode;
156
+ if (typeof own === "string" && own.trim()) return own.trim();
157
+ return typeof item?.name === "string" ? item.name : "";
158
+ }
159
+
160
+ /**
161
+ * The `_id` of one item embedded on an actor.
162
+ *
163
+ * Seeded by the owning actor, because an embedded id must be unique within its
164
+ * **parent document** and nothing wider — so the namespace is already exactly
165
+ * the scope the uniqueness is required in, and the largest namespace in the
166
+ * four corpora holds 180 items, at which 64 bits collide with probability
167
+ * around 10⁻¹⁵.
168
+ *
169
+ * **It takes no index** (#268). Keying on a position meant reordering a being's
170
+ * item list renumbered every id after the change, so a re-import created new
171
+ * documents beside the old ones — while nothing about those documents had
172
+ * changed, only their neighbours. The identity always exists or must be stated;
173
+ * see {@link embeddedIdentity}.
174
+ *
175
+ * Keyed by the **document subtype**, so renaming a note type (#78) leaves every
176
+ * embedded id where it was.
177
+ *
178
+ * @param {string} actorId - The owning actor's id.
179
+ * @param {string} subType - The Foundry Item subtype.
180
+ * @param {string} identity - From {@link embeddedIdentity}.
181
+ * @returns {string} A 16-character Foundry id.
182
+ */
183
+ export function embeddedItemId(actorId, subType, identity) {
184
+ return makeId(actorId, itemAddress(subType, identity));
185
+ }
186
+
187
+ /**
188
+ * Load every JSON file under each of `itemsSourceDirs`, returning one Map keyed
189
+ * by {@link itemAddress} — the compiled document's **subtype** and its
190
+ * `system.shortcode`. Folder docs and entries without a shortcode are skipped.
191
+ * The `_key` field is stripped from each entry — it is not part of the item
192
+ * data model.
193
+ *
194
+ * The directories are read as one address space, because an actor names an item
195
+ * by `(type, shortcode)` and never by the pack it happens to ship in. Two local
196
+ * Item packs claiming the same address is therefore ambiguous rather than a
197
+ * last-one-wins ordering detail, and fails here.
198
+ *
199
+ * A **foreign** directory — the extracted item catalogue of a package this
200
+ * repository depends on but does not contain — is a fallback rather than a
201
+ * peer. A repository must be able to ship its own `skill:awar` that stands in
202
+ * front of the system's, so a local address shadows a foreign one instead of
203
+ * colliding with it. Local directories are therefore read first, and anything
204
+ * already claimed is left alone.
205
+ *
206
+ * @param {readonly string[]} itemsSourceDirs - Every local Item pack's JSON tree.
207
+ * @param {readonly string[]} [foreignSourceDirs] - Extracted dependency
208
+ * catalogues, consulted only for addresses no local pack defines.
209
+ * @returns {Map<string, object>} The predefined items, by address.
210
+ */
211
+ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
212
+ const map = new Map();
213
+ const source = new Map();
214
+ const shadowed = [];
215
+ for (const itemsSourceDir of itemsSourceDirs) {
216
+ if (!fs.existsSync(itemsSourceDir)) {
217
+ // The generator orders the actors pass after every Item pass (#73),
218
+ // so a whole-package build cannot reach this. What can is a run
219
+ // restricted to this one pack, or a caller constructing the
220
+ // compiler itself — neither of which reordering a pack list fixes,
221
+ // so the message no longer suggests it.
222
+ throw new Error(
223
+ `Items source directory ${itemsSourceDir} does not exist — ` +
224
+ `an actor resolves its embedded items against the Item ` +
225
+ `packs' compiled output, so those packs must be compiled ` +
226
+ `before this one`,
227
+ );
228
+ }
229
+ for (const name of fs.readdirSync(itemsSourceDir)) {
230
+ if (!name.endsWith(".json")) continue;
231
+ if (name.startsWith("folder_")) continue;
232
+ const full = path.join(itemsSourceDir, name);
233
+ let doc;
234
+ try {
235
+ doc = JSON.parse(fs.readFileSync(full, "utf8"));
236
+ } catch (err) {
237
+ emitDiagnostic({
238
+ file: full,
239
+ severity: "warning",
240
+ message: `unparseable item JSON, skipping: ${err.message}`,
241
+ });
242
+ continue;
243
+ }
244
+ const shortcode = doc?.system?.shortcode;
245
+ if (!doc?.type || !shortcode) continue;
246
+ const address = itemAddress(doc.type, shortcode);
247
+ const owner = source.get(address);
248
+ if (owner && owner !== itemsSourceDir) {
249
+ throw new Error(
250
+ `Two Item packs both define "${address}" (${owner} and ` +
251
+ `${itemsSourceDir}); an actor addresses an item by ` +
252
+ `(type, shortcode), so the address must be unique across ` +
253
+ `every Item pack`,
254
+ );
255
+ }
256
+ source.set(address, itemsSourceDir);
257
+ // eslint-disable-next-line no-unused-vars
258
+ const { _key, ...rest } = doc;
259
+ map.set(address, rest);
260
+ }
261
+ }
262
+ for (const foreignDir of foreignSourceDirs) {
263
+ for (const name of fs.readdirSync(foreignDir)) {
264
+ if (!name.endsWith(".json")) continue;
265
+ if (name.startsWith("folder_")) continue;
266
+ const full = path.join(foreignDir, name);
267
+ let doc;
268
+ try {
269
+ doc = JSON.parse(fs.readFileSync(full, "utf8"));
270
+ } catch (err) {
271
+ emitDiagnostic({
272
+ file: full,
273
+ severity: "warning",
274
+ message: `unparseable item JSON, skipping: ${err.message}`,
275
+ });
276
+ continue;
277
+ }
278
+ const shortcode = doc?.system?.shortcode;
279
+ if (!doc?.type || !shortcode) continue;
280
+ const address = itemAddress(doc.type, shortcode);
281
+ if (map.has(address)) {
282
+ // Deliberate: this repository defines it, so its version wins.
283
+ if (source.has(address)) shadowed.push(address);
284
+ continue;
285
+ }
286
+ // eslint-disable-next-line no-unused-vars
287
+ const { _key, ...rest } = doc;
288
+ map.set(address, rest);
289
+ }
290
+ }
291
+ if (shadowed.length) {
292
+ log.info(
293
+ `${shadowed.length} dependency item(s) shadowed by this ` +
294
+ `repository's own: ${shadowed.slice(0, 5).join(", ")}` +
295
+ (shadowed.length > 5 ? ", …" : ""),
296
+ );
297
+ }
298
+ return map;
299
+ }
300
+
301
+ /**
302
+ * The Actor compile pass of one game system.
303
+ *
304
+ * A subclass declares its {@link SystemActorCompiler.documentSubtypes} and
305
+ * implements `buildEntry`, which is where the system's own data model is: what
306
+ * an actor's `system` block holds, which art it defaults to, which frontmatter
307
+ * becomes embedded items. Nothing above that line is a system's business.
308
+ */
309
+ export class SystemActorCompiler extends BasePackCompiler {
310
+ static id = "actors";
311
+ static label = "actor";
312
+
313
+ /**
314
+ * Which `(actor, subType:identity)` each resolved entry claimed, and the
315
+ * entry that claimed it first.
316
+ *
317
+ * The scope the check needs is **one note**, and the actor's id supplies it
318
+ * for free: it is unique per note, so keying the map by it scopes the claim
319
+ * without this having to be told where a note begins and ends. That matters
320
+ * because {@link SystemActorCompiler#resolveEmbedded} is called per entry
321
+ * and has no note lifecycle of its own.
322
+ *
323
+ * `frontmatter-lint.mjs` makes the same finding from frontmatter alone
324
+ * (#228), and this does not replace it — the lint is a separate command, so
325
+ * without a check here a colliding pair would compile to two documents with
326
+ * one `_id` and reach the LevelDB packer as an opaque duplicate key.
327
+ *
328
+ * @type {Map<string, string>}
329
+ */
330
+ #embeddedClaims = new Map();
331
+
332
+ // An actor's embedded items are resolved against the *output* of the item
333
+ // passes, so every Item pack compiles before this one. Declared rather than
334
+ // left to the order `packs:` happens to list (#73).
335
+ static readsPackOutputOf = Object.freeze(["Item"]);
336
+
337
+ /**
338
+ * An Actor **is** a system's data, so this pack takes only notes carrying
339
+ * this system's block (#58).
340
+ */
341
+ static requiresSystemBlock = true;
342
+
343
+ /**
344
+ * The note-type → document-subtype map this pass compiles against.
345
+ *
346
+ * Stated by the class rather than reached for through a module import, so
347
+ * every subtype decision the pass makes — the actor's own, and each
348
+ * embedded item reference's — reads one declaration that a subclass
349
+ * compiling for another system replaces.
350
+ *
351
+ * @type {import("./document-subtypes.mjs").DocumentSubtypeMap|undefined}
352
+ */
353
+ static documentSubtypes = undefined;
354
+
355
+ /** @type {readonly string[]} */
356
+ itemsSourceDirs;
357
+ foreignSourceDirs;
358
+
359
+ constructor({ itemsSourceDirs = [], foreignSourceDirs = [], ...options }) {
360
+ super(options);
361
+ // Where the items passes wrote their JSON. Stated by the caller rather
362
+ // than assumed to be this pack's sibling: the packs' locations are
363
+ // configuration, and a consumer may put them anywhere (#1508). Every
364
+ // Item pack, because a repository may ship more than one (#1566).
365
+ //
366
+ // **Optional, and empty is a legitimate package (#49).** This used to
367
+ // throw unless at least one Item pack was declared, which asked a
368
+ // package to declare the very thing it may exist not to have. An Item
369
+ // pack is system-bound by construction — Foundry requires `system` on
370
+ // Item packs — so a deliberately system-agnostic module could satisfy
371
+ // the guard only by naming a system. `harn-ensemble` is the case:
372
+ // 2,512 beings whose embedded items address the `sohl` and `hm3`
373
+ // catalogues, and five affiliation notes of its own.
374
+ //
375
+ // The guard also did not test what it claimed. It counted *declared
376
+ // directories*, not resolvable items, so an empty Item pack satisfied
377
+ // it while a being naming a missing item still failed later. The
378
+ // condition actually cared about is checked where it can be reported
379
+ // precisely: {@link SystemActorCompiler#resolveEmbedded} already errors
380
+ // per unresolved `(type, shortcode)`, naming the actor. A package whose
381
+ // actors embed nothing, or whose every address resolves against a
382
+ // dependency catalogue through `foreignSourceDirs`, now compiles with
383
+ // no Item pack at all — and one that is genuinely missing an item
384
+ // still fails, saying which item and which actor rather than which
385
+ // pack is absent.
386
+ Object.defineProperty(this, "itemsSourceDirs", {
387
+ value: Object.freeze([...itemsSourceDirs]),
388
+ writable: false,
389
+ });
390
+ // The dependency catalogues, if any. Not required: a repository that
391
+ // holds every item its actors name needs none, and one that declares
392
+ // no `itemCatalog: true` relationship gets an empty list.
393
+ Object.defineProperty(this, "foreignSourceDirs", {
394
+ value: Object.freeze([...foreignSourceDirs]),
395
+ writable: false,
396
+ });
397
+ }
398
+
399
+ /**
400
+ * This pass's system map, or a message naming the class that forgot it.
401
+ *
402
+ * @returns {import("./document-subtypes.mjs").DocumentSubtypeMap} The map.
403
+ */
404
+ get documentSubtypes() {
405
+ const map = /** @type {typeof SystemActorCompiler} */ (this.constructor).documentSubtypes;
406
+ if (!map) {
407
+ throw new Error(
408
+ `${this.constructor.name} declares no \`documentSubtypes\` — an ` +
409
+ `Actor pass reads its system's map to know which notes it ` +
410
+ `claims and what each becomes.`,
411
+ );
412
+ }
413
+ return map;
414
+ }
415
+
416
+ /**
417
+ * The frontmatter block this pass reads.
418
+ *
419
+ * @returns {string} The system's block name.
420
+ */
421
+ get system() {
422
+ return this.documentSubtypes.block;
423
+ }
424
+
425
+ /**
426
+ * @param {object} fm - The note's frontmatter.
427
+ * @returns {boolean} True for a note type the system maps onto an `Actor`.
428
+ */
429
+ selects(fm) {
430
+ return mapsNoteType(this.documentSubtypes, fm.type, "Actor");
431
+ }
432
+
433
+ /**
434
+ * The predefined items each actor's embedded items resolve against, loaded
435
+ * before the walk from the items passes' output.
436
+ *
437
+ * @returns {Promise<void>}
438
+ */
439
+ async prepare() {
440
+ await super.prepare();
441
+ this.itemsMap = loadItemsMap(this.itemsSourceDirs, this.foreignSourceDirs);
442
+ log.info(`Loaded ${this.itemsMap.size} predefined items for actor resolution`);
443
+ }
444
+
445
+ /** @inheritdoc */
446
+ reportDetail(stats) {
447
+ log.debug(
448
+ `Skipped ${stats.skippedOther} non-actor file(s) ` +
449
+ `(not ${noteTypesFor(this.documentSubtypes, "Actor").join("/")}, ` +
450
+ `package:${contentPackage()})`,
451
+ );
452
+ }
453
+
454
+ /**
455
+ * The Foundry Item subtype an embedded reference's `type` addresses.
456
+ *
457
+ * **The reference is in the note vocabulary; the address is in the
458
+ * document's** (#140). An actor writes `(type, shortcode)` with the type an
459
+ * author authors, while {@link itemAddress} keys the predefined items by
460
+ * the subtype each compiled document carries — so exactly one of the two
461
+ * sides has to translate, and it is this one. The system's map is a
462
+ * function from note type to subtype by construction; the reverse is not,
463
+ * and a compiled document records nothing about the note that produced it,
464
+ * so there is no honest way to key the addresses the other way round.
465
+ *
466
+ * @param {string} type - The type the reference names.
467
+ * @returns {import("./document-subtypes.mjs").ReferencedSubtype} The
468
+ * subtype, or why the reference names none.
469
+ */
470
+ embeddedSubtype(type) {
471
+ return referencedSubtype(this.documentSubtypes, type, "Item");
472
+ }
473
+
474
+ /**
475
+ * Resolve one embedded item from a `(type, shortcode?, overlay)`
476
+ * descriptor. If `shortcode` is given, the predefined item is fetched
477
+ * from `itemsMap` and the overlay deep-merged on top. If absent, the
478
+ * descriptor must carry enough fields to stand alone. The embedded
479
+ * item's `_id` is regenerated deterministically from
480
+ * `(actorId, subType, shortcode, indexKey)` so re-exports are stable —
481
+ * from the **document subtype**, so that renaming a note type (#78) leaves
482
+ * every embedded id exactly where it was.
483
+ * Returns null if the descriptor cannot be resolved.
484
+ *
485
+ * @param {Map<string, object>} itemsMap - The predefined items, by address.
486
+ * @param {string} actorId - The owning actor's id, seeding embedded ids.
487
+ * @param {string} type - The **note** type the reference names.
488
+ * @param {string|null} shortcode - The referenced item's shortcode, or
489
+ * `null` for a stand-alone entry.
490
+ * @param {object} [overlay] - The entry's remaining properties.
491
+ * @param {string} indexKey - Where the reference sits, for a diagnostic.
492
+ * It no longer reaches the id (#268) — it names the entry in a message.
493
+ * @param {string} ctx - Diagnostic context (the actor's label).
494
+ * @param {object} [at] - Where to locate a finding.
495
+ * @param {string} [at.fmKey] - The frontmatter key the reference sits
496
+ * under, so an unresolved one is reported at the reference rather than
497
+ * at the note.
498
+ * @returns {object|null} The embedded item, or null when it resolved to
499
+ * nothing — always with a finding emitted.
500
+ */
501
+ resolveEmbedded(itemsMap, actorId, type, shortcode, overlay, indexKey, ctx, { fmKey } = {}) {
502
+ // Where a finding about this reference points. The value locates the
503
+ // exact entry in a list; the key is the fallback when it cannot be
504
+ // found, which still beats naming the note alone.
505
+ const where = () =>
506
+ locateFrontmatterKey(this.currentNote?.absPath, fmKey ?? "items", shortcode || type);
507
+
508
+ const { subType, problem } = this.embeddedSubtype(type);
509
+ if (problem) {
510
+ this.noteError(`${ctx}: ${indexKey}: ${problem}`, where());
511
+ this.errorCount++;
512
+ return null;
513
+ }
514
+ const address = itemAddress(/** @type {string} */ (subType), shortcode ?? "");
515
+
516
+ let base = null;
517
+ if (shortcode) {
518
+ base = itemsMap.get(address);
519
+ if (!base) {
520
+ // Both vocabularies where they differ, so an author sees why an
521
+ // address they wrote did not land where they expected.
522
+ const translated =
523
+ subType === type ? "" : (
524
+ ` (looked up as "${address}", the ` +
525
+ `${this.documentSubtypes.system} Item subtype a ` +
526
+ `"${type}" note compiles into)`
527
+ );
528
+ this.noteError(
529
+ `${ctx}: no predefined item for "${type}:${shortcode}"${translated}`,
530
+ where(),
531
+ );
532
+ this.errorCount++;
533
+ return null;
534
+ }
535
+ base = stripCompendiumFields(base);
536
+ } else if (overlay && overlay.name && overlay.system) {
537
+ base = { type: subType, name: overlay.name, system: {} };
538
+ } else {
539
+ this.noteError(
540
+ `${ctx}: embedded item missing shortcode and not enough fields to stand alone`,
541
+ where(),
542
+ );
543
+ this.errorCount++;
544
+ return null;
545
+ }
546
+ const merged = overlay ? deepMerge(base, overlay) : base;
547
+ merged.type = subType;
548
+ const identity = embeddedIdentity(merged);
549
+ const claim = `${actorId}\u0000${itemAddress(/** @type {string} */ (subType), identity)}`;
550
+ const first = this.#embeddedClaims.get(claim);
551
+ if (first !== undefined) {
552
+ this.noteError(
553
+ `${ctx}: ${indexKey}: ` +
554
+ `"${itemAddress(/** @type {string} */ (subType), identity)}" is ` +
555
+ `already the identity of "${first}" on this actor. ` +
556
+ `(type, shortcode) says *which entity* an embedded item is, ` +
557
+ `so two entries sharing one denote a single thing and every ` +
558
+ `lookup by it is ambiguous. Give this entry its own ` +
559
+ `\`system.shortcode\`` +
560
+ (shortcode ?
561
+ ` — a top-level \`shortcode\` only selects the template ` +
562
+ `this entry is written from and never reaches the document`
563
+ : "") +
564
+ `, or delete it if it is a duplicate.`,
565
+ where(),
566
+ );
567
+ this.errorCount++;
568
+ return null;
569
+ }
570
+ this.#embeddedClaims.set(claim, indexKey);
571
+ merged._id = embeddedItemId(actorId, /** @type {string} */ (subType), identity);
572
+ // Foundry's pack compiler flattens the document hierarchy into LevelDB,
573
+ // storing each embedded document under its own `_key`. Embedded items
574
+ // therefore need a hierarchical key, as do any effects they carry
575
+ // (re-keyed under this actor's item rather than the items-pack key they
576
+ // inherited). Mirrors the items pack convention in the Item compiler.
577
+ merged._key = `!actors.items!${actorId}.${merged._id}`;
578
+ if (Array.isArray(merged.effects)) {
579
+ for (const effect of merged.effects) {
580
+ if (!effect?._id) continue;
581
+ effect._key = `!actors.items.effects!${actorId}.${merged._id}.${effect._id}`;
582
+ }
583
+ }
584
+ return merged;
585
+ }
586
+ }
@@ -14,19 +14,22 @@
14
14
  /**
15
15
  * The one charset every segment of a canonical address is held to (#59).
16
16
  *
17
- * An address is a hyphen-joined tuple — `sohl-skill-clmb` — and it is read back
18
- * by **counting segments**, with a fixed meaning per position. That is sound
19
- * for exactly one reason: the hyphen is *purely* a separator, because no
20
- * segment may contain one. Take that away and reading an address needs a
21
- * vocabulary to match against, a longest-match rule, and an answer for every
22
- * name that is a prefix of another — none of which exist.
17
+ * An address is a hyphen-joined tuple — `package-system-type-shortcode`, so
18
+ * `sohl-none-doc-gear` — and it is read back by **counting segments**, with a
19
+ * fixed meaning per position. That is sound for exactly one reason: the hyphen
20
+ * is *purely* a separator, because no segment may contain one. Take that away
21
+ * and reading an address needs a vocabulary to match against, a longest-match
22
+ * rule, and an answer for every name that is a prefix of another — none of
23
+ * which exist.
23
24
  *
24
25
  * So the charset is not a tidiness rule. It is the premise the address grammar
25
26
  * rests on, and the issue's word for how it should be held is **enforced rather
26
27
  * than assumed**: a value that breaks it is refused where it is written, not
27
28
  * discovered later as addresses that fail to parse and report nothing about
28
- * why. `harn-adventures` was that case — its keys read as four segments and
29
- * failed as a `null` return.
29
+ * why. `harn-adventures` was that case — a package whose own name carries the
30
+ * separator emits keys one segment too long, so `harn-adventures-sohl-skill-melee`
31
+ * counts five where the grammar requires four, and every one of them failed as
32
+ * a `null` return.
30
33
  *
31
34
  * This module is a **leaf with no local imports**, so the validator a
32
35
  * consumer's `package-build.config.mjs` reaches (`content-config.mjs`) can name