@heroiclands/package-build 0.6.1 → 3.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.
- package/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +58 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -35
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- package/types/sohl/note-schemas.d.mts +7 -0
package/sohl/actors.mjs
ADDED
|
@@ -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
|
+
]);
|