@heroiclands/package-build 0.6.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +68 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,581 @@
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
+ * Actors pack compiler — produces JSON pack files for the "actors" Foundry
16
+ * compendium from markdown `being` notes in the `assets/content/` tree.
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`.
22
+ *
23
+ * Each actor's embedded items are resolved by looking up `<type>:<shortcode>`
24
+ * against the generated JSON tree of every Item pack (built in prior items
25
+ * passes and named by the caller as `itemsSourceDirs`). All of them, because a
26
+ * repository may group its items into several Item packs (#1566) and a being
27
+ * may hold items from any of them.
28
+ * Attributes (`sohl.attributes` map) become embedded attribute items with
29
+ * `scoreBase` set from the map value. Each entry in `sohl.items` is similarly
30
+ * resolved by `(type, shortcode)` and deep-merged with the entry's other
31
+ * properties. `sohl.skills` is ignored.
32
+ *
33
+ * Not a standalone script — exports the `Actors` compiler class, imported and
34
+ * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`). Must run
35
+ * after the items pass, since it reads the items pack's generated JSON tree.
36
+ *
37
+ * The walk itself — filtering by package and type, skipping drafts,
38
+ * expanding tables, converting wikilinks, writing the JSON and counting
39
+ * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
40
+ * states only what makes this pass its own (#1509).
41
+ */
42
+
43
+ import fs from "fs";
44
+ import path from "path";
45
+ import log from "loglevel";
46
+
47
+ import {
48
+ sohlField,
49
+ getFrontmatter,
50
+ makeId,
51
+ resolveName,
52
+ resolveImg,
53
+ defaultStats,
54
+ withArchetypeFlag,
55
+ md,
56
+ } from "../engine/helpers.mjs";
57
+ import { emitDiagnostic } from "../engine/diagnostics.mjs";
58
+ import { BasePackCompiler } from "../engine/base-compiler.mjs";
59
+ import { contentPackage } from "../engine/content-package.mjs";
60
+
61
+ /**
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.
64
+ */
65
+ const ACTOR_VAULT_TYPE = "being";
66
+
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.
70
+ const DEFAULT_IMG = {
71
+ being: "systems/sohl/assets/icons/game-icons/delapouite/person.svg",
72
+ };
73
+
74
+ /**
75
+ * Strip compendium-only fields from a predefined item before embedding it
76
+ * inside an actor's `items[]`. These fields belong on a top-level
77
+ * compendium document, not on an embedded one.
78
+ */
79
+ function stripCompendiumFields(item) {
80
+ // eslint-disable-next-line no-unused-vars
81
+ const { _key, _stats, ownership, folder, ...rest } = item;
82
+ return rest;
83
+ }
84
+
85
+ /**
86
+ * Recursively merge `overlay` onto `base`. Plain objects merge key-by-key;
87
+ * everything else (arrays, primitives, null) replaces. Inputs are not
88
+ * mutated.
89
+ */
90
+ function deepMerge(base, overlay) {
91
+ if (overlay === undefined) return base;
92
+ if (!isPlainObject(base) || !isPlainObject(overlay)) {
93
+ return overlay;
94
+ }
95
+ const out = { ...base };
96
+ for (const [k, v] of Object.entries(overlay)) {
97
+ out[k] = k in base ? deepMerge(base[k], v) : v;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function isPlainObject(v) {
103
+ return (
104
+ v !== null &&
105
+ typeof v === "object" &&
106
+ !Array.isArray(v) &&
107
+ Object.getPrototypeOf(v) === Object.prototype
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Normalize a being's persisted `system.body` from a `sohl.body` block. The
113
+ * authoring frontmatter mirrors the schema field-for-field: `sohl.body` nests
114
+ * `structure` / `weight` / `reachBase` / `bodyScaleBase` / `personalFatigue`,
115
+ * exactly like `system.body`.
116
+ */
117
+ function normalizeBody(bodyObj) {
118
+ const b = bodyObj && typeof bodyObj === "object" ? bodyObj : {};
119
+ const weight = b.weight || {};
120
+ return {
121
+ structure: b.structure ?? { parts: [], adjacent: [] },
122
+ weight: {
123
+ base: weight.base == null ? null : Number(weight.base),
124
+ calc: String(weight.calc ?? "0"),
125
+ },
126
+ reachBase: Number(b.reachBase ?? 0) || 0,
127
+ bodyScaleBase: Number(b.bodyScaleBase ?? 1) || 1,
128
+ personalFatigue: String(b.personalFatigue ?? "enc"),
129
+ };
130
+ }
131
+
132
+ /** Normalize per-medium movement profiles from a `sohl.movementProfiles` list. */
133
+ function normalizeMovementProfiles(list) {
134
+ return (Array.isArray(list) ? list : []).map((p) => ({
135
+ medium: String(p.medium ?? "terrestrial"),
136
+ feetPerRound: Number(p.feetPerRound ?? 0) || 0,
137
+ leaguesPerWatch: Number(p.leaguesPerWatch ?? 0) || 0,
138
+ encumbrance: String(p.encumbrance ?? "0"),
139
+ strMod: String(p.strMod ?? "0"),
140
+ disabled: Boolean(p.disabled ?? false),
141
+ }));
142
+ }
143
+
144
+ /**
145
+ * Extract a being's body (+ its movement) from a `sohl` block that mirrors the
146
+ * schema: `sohl.body` (nested → `system.body`) and the flat
147
+ * `sohl.currentMoveMedium` / `sohl.movementProfiles` (→ the base-actor movement
148
+ * fields; movement is a universal actor capability, not part of the body).
149
+ */
150
+ function extractBodyAndMovement(fm) {
151
+ return {
152
+ body: normalizeBody(sohlField(fm, "body", {})),
153
+ currentMoveMedium: String(sohlField(fm, "currentMoveMedium", "none")),
154
+ movementProfiles: normalizeMovementProfiles(
155
+ sohlField(fm, "movementProfiles", []),
156
+ ),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * 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.
165
+ *
166
+ * The directories are read as one address space, because a being names an item
167
+ * by `(type, shortcode)` and never by the pack it happens to ship in. Two local
168
+ * Item packs claiming the same address is therefore ambiguous rather than a
169
+ * last-one-wins ordering detail, and fails here.
170
+ *
171
+ * A **foreign** directory — the extracted item catalogue of a package this
172
+ * repository depends on but does not contain — is a fallback rather than a
173
+ * peer. A repository must be able to ship its own `skill:awar` that stands in
174
+ * front of the system's, so a local address shadows a foreign one instead of
175
+ * colliding with it. Local directories are therefore read first, and anything
176
+ * already claimed is left alone.
177
+ *
178
+ * @param {readonly string[]} itemsSourceDirs - Every local Item pack's JSON tree.
179
+ * @param {readonly string[]} [foreignSourceDirs] - Extracted dependency
180
+ * catalogues, consulted only for addresses no local pack defines.
181
+ * @returns {Map<string, object>} The predefined items, by address.
182
+ */
183
+ function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
184
+ const map = new Map();
185
+ const source = new Map();
186
+ const shadowed = [];
187
+ for (const itemsSourceDir of itemsSourceDirs) {
188
+ if (!fs.existsSync(itemsSourceDir)) {
189
+ throw new Error(
190
+ `Items source directory ${itemsSourceDir} does not exist — actors must be generated after items`,
191
+ );
192
+ }
193
+ for (const name of fs.readdirSync(itemsSourceDir)) {
194
+ if (!name.endsWith(".json")) continue;
195
+ if (name.startsWith("folder_")) continue;
196
+ const full = path.join(itemsSourceDir, name);
197
+ let doc;
198
+ try {
199
+ doc = JSON.parse(fs.readFileSync(full, "utf8"));
200
+ } catch (err) {
201
+ emitDiagnostic({
202
+ file: full,
203
+ severity: "warning",
204
+ message: `unparseable item JSON, skipping: ${err.message}`,
205
+ });
206
+ continue;
207
+ }
208
+ const shortcode = doc?.system?.shortcode;
209
+ if (!doc?.type || !shortcode) continue;
210
+ const address = `${doc.type}:${shortcode}`;
211
+ const owner = source.get(address);
212
+ if (owner && owner !== itemsSourceDir) {
213
+ throw new Error(
214
+ `Two Item packs both define "${address}" (${owner} and ` +
215
+ `${itemsSourceDir}); a being addresses an item by ` +
216
+ `(type, shortcode), so the address must be unique across ` +
217
+ `every Item pack`,
218
+ );
219
+ }
220
+ source.set(address, itemsSourceDir);
221
+ // eslint-disable-next-line no-unused-vars
222
+ const { _key, ...rest } = doc;
223
+ map.set(address, rest);
224
+ }
225
+ }
226
+ for (const foreignDir of foreignSourceDirs) {
227
+ for (const name of fs.readdirSync(foreignDir)) {
228
+ if (!name.endsWith(".json")) continue;
229
+ if (name.startsWith("folder_")) continue;
230
+ const full = path.join(foreignDir, name);
231
+ let doc;
232
+ try {
233
+ doc = JSON.parse(fs.readFileSync(full, "utf8"));
234
+ } catch (err) {
235
+ emitDiagnostic({
236
+ file: full,
237
+ severity: "warning",
238
+ message: `unparseable item JSON, skipping: ${err.message}`,
239
+ });
240
+ continue;
241
+ }
242
+ const shortcode = doc?.system?.shortcode;
243
+ if (!doc?.type || !shortcode) continue;
244
+ const address = `${doc.type}:${shortcode}`;
245
+ if (map.has(address)) {
246
+ // Deliberate: this repository defines it, so its version wins.
247
+ if (source.has(address)) shadowed.push(address);
248
+ continue;
249
+ }
250
+ // eslint-disable-next-line no-unused-vars
251
+ const { _key, ...rest } = doc;
252
+ map.set(address, rest);
253
+ }
254
+ }
255
+ if (shadowed.length) {
256
+ log.info(
257
+ `${shadowed.length} dependency item(s) shadowed by this ` +
258
+ `repository's own: ${shadowed.slice(0, 5).join(", ")}` +
259
+ (shadowed.length > 5 ? ", …" : ""),
260
+ );
261
+ }
262
+ return map;
263
+ }
264
+
265
+ /**
266
+ * Extract the body of an H1 section whose heading carries the explicit
267
+ * anchor decorator `{#<anchorId>}`. Captures every line after the H1 up
268
+ * to (but not including) the next H1 — nested H2/H3 etc. and their bodies
269
+ * are included. The H1 line itself is discarded. Returns "" if no such
270
+ * heading exists. Fenced code blocks are respected so `# foo` inside
271
+ * ``` blocks does not trigger a match.
272
+ */
273
+ function extractAnchorSection(body, anchorId) {
274
+ const lines = body.split("\n");
275
+ const captured = [];
276
+ let inCodeBlock = false;
277
+ let capturing = false;
278
+ const wanted = String(anchorId).toLowerCase();
279
+ for (const line of lines) {
280
+ if (line.trim().startsWith("```")) {
281
+ inCodeBlock = !inCodeBlock;
282
+ if (capturing) captured.push(line);
283
+ continue;
284
+ }
285
+ const h1Match =
286
+ !inCodeBlock ? line.match(/^\s*#\s+(.+?)\s*#*\s*$/) : null;
287
+ if (h1Match) {
288
+ const anchor = h1Match[1].match(/\{#([^}]+)\}\s*$/);
289
+ const id = anchor?.[1]?.trim().toLowerCase() || null;
290
+ if (capturing) break;
291
+ if (id === wanted) {
292
+ capturing = true;
293
+ continue;
294
+ }
295
+ }
296
+ if (capturing) captured.push(line);
297
+ }
298
+ return captured.join("\n").trim();
299
+ }
300
+
301
+ /**
302
+ * Render an extracted markdown section to HTML, or "" if empty.
303
+ */
304
+ function renderSection(body, anchorId) {
305
+ const slice = extractAnchorSection(body, anchorId);
306
+ return slice ? md.render(slice) : "";
307
+ }
308
+
309
+ export class Actors extends BasePackCompiler {
310
+ static id = "actors";
311
+ static label = "actor";
312
+
313
+ /** @type {readonly string[]} */
314
+ itemsSourceDirs;
315
+ foreignSourceDirs;
316
+
317
+ constructor({ itemsSourceDirs, foreignSourceDirs = [], ...options }) {
318
+ super(options);
319
+ // Where the items passes wrote their JSON. Stated by the caller rather
320
+ // than assumed to be this pack's sibling: the packs' locations are
321
+ // configuration, and a consumer may put them anywhere (#1508). Every
322
+ // Item pack, because a repository may ship more than one (#1566).
323
+ if (!itemsSourceDirs?.length) {
324
+ throw new Error(
325
+ "Actors compiler requires `itemsSourceDirs` — the generated JSON " +
326
+ "of every Item pack, which each being's embedded items are " +
327
+ "resolved against. Declare at least one pack of type " +
328
+ '"Item" in package-build.config.yaml.',
329
+ );
330
+ }
331
+ Object.defineProperty(this, "itemsSourceDirs", {
332
+ value: Object.freeze([...itemsSourceDirs]),
333
+ writable: false,
334
+ });
335
+ // The dependency catalogues, if any. Not required: a repository that
336
+ // holds every item its beings name needs none, and one that declares
337
+ // no `itemCatalog: true` relationship gets an empty list.
338
+ Object.defineProperty(this, "foreignSourceDirs", {
339
+ value: Object.freeze([...foreignSourceDirs]),
340
+ writable: false,
341
+ });
342
+ }
343
+
344
+ /**
345
+ * @param {object} fm - The note's frontmatter.
346
+ * @returns {boolean} True for a `being` note.
347
+ */
348
+ selects(fm) {
349
+ return fm.type === ACTOR_VAULT_TYPE;
350
+ }
351
+
352
+ /**
353
+ * The predefined items each being's embedded items resolve against, loaded
354
+ * before the walk from the items passes' output.
355
+ *
356
+ * @returns {Promise<void>}
357
+ */
358
+ async prepare() {
359
+ await super.prepare();
360
+ this.itemsMap = loadItemsMap(
361
+ this.itemsSourceDirs,
362
+ this.foreignSourceDirs,
363
+ );
364
+ log.info(
365
+ `Loaded ${this.itemsMap.size} predefined items for actor resolution`,
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Compile one `being` note into its actor document.
371
+ *
372
+ * @param {object} fm - The note's frontmatter.
373
+ * @param {string} markdown - The body, tables expanded and wikilinks
374
+ * resolved.
375
+ * @returns {object} The actor document, keyed for the pack.
376
+ */
377
+ buildEntry(fm, markdown) {
378
+ return this.buildBeing(this.itemsMap, fm, markdown);
379
+ }
380
+
381
+ /** @inheritdoc */
382
+ reportDetail(stats) {
383
+ log.debug(
384
+ `Skipped ${stats.skippedOther} non-actor file(s) ` +
385
+ `(not ${ACTOR_VAULT_TYPE}, package:${contentPackage()})`,
386
+ );
387
+ }
388
+
389
+ /**
390
+ * Resolve one embedded item from a `(type, shortcode?, overlay)`
391
+ * descriptor. If `shortcode` is given, the predefined item is fetched
392
+ * from `itemsMap` and the overlay deep-merged on top. If absent, the
393
+ * descriptor must carry enough fields to stand alone. The embedded
394
+ * item's `_id` is regenerated deterministically from
395
+ * `(actorId, type, shortcode, indexKey)` so re-exports are stable.
396
+ * Returns null if the descriptor cannot be resolved.
397
+ */
398
+ resolveEmbedded(
399
+ itemsMap,
400
+ actorId,
401
+ type,
402
+ shortcode,
403
+ overlay,
404
+ indexKey,
405
+ ctx,
406
+ ) {
407
+ let base = null;
408
+ if (shortcode) {
409
+ base = itemsMap.get(`${type}:${shortcode}`);
410
+ if (!base) {
411
+ this.noteError(
412
+ `${ctx}: no predefined item for "${type}:${shortcode}"`,
413
+ );
414
+ this.errorCount++;
415
+ return null;
416
+ }
417
+ base = stripCompendiumFields(base);
418
+ } else if (overlay && overlay.name && overlay.system) {
419
+ base = { type, name: overlay.name, system: {} };
420
+ } else {
421
+ this.noteError(
422
+ `${ctx}: embedded item missing shortcode and not enough fields to stand alone`,
423
+ );
424
+ this.errorCount++;
425
+ return null;
426
+ }
427
+ const merged = overlay ? deepMerge(base, overlay) : base;
428
+ merged.type = type;
429
+ merged._id = makeId(
430
+ actorId,
431
+ `${type}:${shortcode || merged.name}:${indexKey}`,
432
+ );
433
+ // Foundry's pack compiler flattens the document hierarchy into LevelDB,
434
+ // storing each embedded document under its own `_key`. Embedded items
435
+ // therefore need a hierarchical key, as do any effects they carry
436
+ // (re-keyed under this actor's item rather than the items-pack key they
437
+ // inherited). Mirrors the items pack convention in items.mjs.
438
+ merged._key = `!actors.items!${actorId}.${merged._id}`;
439
+ if (Array.isArray(merged.effects)) {
440
+ for (const effect of merged.effects) {
441
+ if (!effect?._id) continue;
442
+ effect._key = `!actors.items.effects!${actorId}.${merged._id}.${effect._id}`;
443
+ }
444
+ }
445
+ return merged;
446
+ }
447
+
448
+ /**
449
+ * Build all embedded items for an actor: one per `sohl.attributes`
450
+ * entry plus one per `sohl.items` entry. `sohl.skills` is ignored.
451
+ */
452
+ buildEmbeddedItems(itemsMap, actorId, fm, ctx) {
453
+ const items = [];
454
+
455
+ const attributes = sohlField(fm, "attributes", null);
456
+ if (attributes && typeof attributes === "object") {
457
+ for (const [shortcode, value] of Object.entries(attributes)) {
458
+ const overlay = { system: { scoreBase: Number(value) || 0 } };
459
+ const embedded = this.resolveEmbedded(
460
+ itemsMap,
461
+ actorId,
462
+ "attribute",
463
+ shortcode,
464
+ overlay,
465
+ `attr:${shortcode}`,
466
+ ctx,
467
+ );
468
+ if (embedded) items.push(embedded);
469
+ }
470
+ }
471
+
472
+ const sohlItems = sohlField(fm, "items", null);
473
+ if (Array.isArray(sohlItems)) {
474
+ sohlItems.forEach((entry, index) => {
475
+ if (!entry || typeof entry !== "object") {
476
+ this.noteError(
477
+ `${ctx}: sohl.items[${index}] is not an object`,
478
+ );
479
+ this.errorCount++;
480
+ return;
481
+ }
482
+ const { shortcode, type, ...rest } = entry;
483
+ if (!type) {
484
+ this.noteError(`${ctx}: sohl.items[${index}] missing type`);
485
+ this.errorCount++;
486
+ return;
487
+ }
488
+ const embedded = this.resolveEmbedded(
489
+ itemsMap,
490
+ actorId,
491
+ type,
492
+ shortcode || null,
493
+ rest,
494
+ `items:${index}`,
495
+ ctx,
496
+ );
497
+ if (embedded) items.push(embedded);
498
+ });
499
+ }
500
+
501
+ return items;
502
+ }
503
+
504
+ buildBeing(itemsMap, fm, body) {
505
+ const name = resolveName(fm);
506
+ const id = fm.id;
507
+ const ctx = `actor "${name}"`;
508
+
509
+ const items = this.buildEmbeddedItems(itemsMap, id, fm, ctx);
510
+
511
+ const folderId = sohlField(fm, "folder", null);
512
+ const folder = this.folderResolver(folderId);
513
+
514
+ const system = {
515
+ // The frontmatter shortcode is the actor's stable `(type, shortcode)`
516
+ // key — and, for a `docArchetype`-flagged being, its archetype
517
+ // identity (the dedup/override key of the Create-dialog picker, #604).
518
+ shortcode: fm.shortcode || "",
519
+ portrait: resolveImg(fm.portrait) || DEFAULT_IMG.being,
520
+ appearance: renderSection(body || "", "appearance"),
521
+ dossier: renderSection(body || "", "dossier"),
522
+ };
523
+
524
+ // Fill `system.body` (+ the base-actor movement fields) from the being's
525
+ // frontmatter, rather than embedding a corpus item (#535). The `sohl`
526
+ // block mirrors `system` field-for-field: `sohl.body` nests the body
527
+ // (`structure` / `weight` / …), with `currentMoveMedium` /
528
+ // `movementProfiles` flat alongside it. An **incorporeal** being omits
529
+ // `sohl.body` and keeps the schema's empty body.
530
+ const bodyField = sohlField(fm, "body", null);
531
+ if (bodyField && typeof bodyField === "object") {
532
+ const bodyData = extractBodyAndMovement(fm);
533
+ system.body = bodyData.body;
534
+ system.currentMoveMedium = bodyData.currentMoveMedium;
535
+ system.movementProfiles = bodyData.movementProfiles;
536
+ } else if (bodyField != null) {
537
+ this.noteError(
538
+ `${ctx}: sohl.body must be an inline object (structure/weight/…), got ${typeof bodyField}`,
539
+ );
540
+ this.errorCount++;
541
+ }
542
+
543
+ // Being-only combat grouping (mirrors `system.defaultCombatGroup`).
544
+ const defaultCombatGroup = sohlField(
545
+ fm,
546
+ "defaultCombatGroup",
547
+ undefined,
548
+ );
549
+ if (defaultCombatGroup !== undefined) {
550
+ system.defaultCombatGroup = defaultCombatGroup;
551
+ }
552
+
553
+ return {
554
+ name,
555
+ type: "being",
556
+ img: resolveImg(fm.img) || DEFAULT_IMG.being,
557
+ _id: id,
558
+ system,
559
+ items,
560
+ prototypeToken: {
561
+ name,
562
+ displayName: 0,
563
+ actorLink: false,
564
+ texture: { src: resolveImg(fm.img) || DEFAULT_IMG.being },
565
+ width: 1,
566
+ height: 1,
567
+ sight: { enabled: false },
568
+ detectionModes: [],
569
+ },
570
+ effects: [],
571
+ folder,
572
+ sort: 0,
573
+ ownership: { default: 0 },
574
+ // `sohl.archetype` (required nullable number) drives
575
+ // `flags.sohl.docArchetype` (#640 / archetype contract #604).
576
+ flags: withArchetypeFlag(fm, fm.flags, ctx),
577
+ _stats: defaultStats(),
578
+ _key: `!actors!${id}`,
579
+ };
580
+ }
581
+ }
@@ -0,0 +1,44 @@
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 affiliation standings an authored `relation` map may use — one
16
+ * affiliation's stance toward another (#1404).
17
+ *
18
+ * Plain ESM for the same reason `./default-item-art.mjs` is: the pack scripts
19
+ * run under bare `node`, so they cannot read the runtime's TypeScript
20
+ * `AFFILIATION_STANDING`. This module is where the pipeline reads the list
21
+ * from, rather than restating it inside a compiler (#1510).
22
+ *
23
+ * The runtime keeps its own `defineType("SOHL.Affiliation.Standing", { … })`
24
+ * declaration in `src/utils/constants.ts`, because that literal is *parsed out
25
+ * of the source* by two build guards — the generated type catalog
26
+ * (`utils/build-type-catalog.mjs`) and the localization-coverage check
27
+ * (`utils/check-lang-coverage.mjs`) — and replacing it with an imported
28
+ * reference would blind both. The two lists are held identical by a test
29
+ * (`tests/build/src-import-severance.test.ts`), so the drift the
30
+ * duplication would otherwise allow is a failing build rather than an
31
+ * affiliation whose authored hostility quietly became neutrality.
32
+ *
33
+ * @type {readonly string[]}
34
+ */
35
+ export const AFFILIATION_STANDINGS = Object.freeze([
36
+ // Allied or friendly toward that affiliation.
37
+ "aligned",
38
+ // Neutral — no particular standing. The default for an unlisted affiliation.
39
+ "unaligned",
40
+ // In competition; opposed but not implacable.
41
+ "rival",
42
+ // Actively hostile.
43
+ "nemesis",
44
+ ]);