@heroiclands/package-build 3.2.0 → 3.4.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.
@@ -0,0 +1,280 @@
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
+ * Build-time Skill Base evaluation — the small part of SoHL's `SafeExpression`
16
+ * needed to compile a skill's opening mastery level into the pack (#46).
17
+ *
18
+ * A skill's `skillBaseFormula` is a `SafeExpression` in the `skill.base` scope:
19
+ * an expression over one binding, `attr` (attribute scores by shortcode), with
20
+ * the helper library in scope — in practice always `sb(attr.x, attr.y)`. The
21
+ * client evaluates it in `SkillLogic.computeSkillBase`. Nothing here talks to
22
+ * Foundry, so this reproduces the evaluation rather than importing it.
23
+ *
24
+ * **Reproduced deliberately, and it must not drift**: if SoHL changes `sb()`'s
25
+ * rounding or the clamp, a pack compiled here and the client reading it stop
26
+ * agreeing. The two rules copied are:
27
+ *
28
+ * - `sb()` (SoHL `ExpressionHelperRegistry`) — one value is itself; two are
29
+ * averaged and rounded **up** iff the first exceeds the second, **down**
30
+ * otherwise (so equal values round down); three or more are averaged and
31
+ * rounded to nearest.
32
+ * - The clamp (SoHL `SkillLogic.computeSkillBase`) — the result is
33
+ * `Math.max(0, n)`, and a formula that does not yield a finite number is an
34
+ * error rather than a silent zero.
35
+ *
36
+ * What is **not** reproduced is the rest of the grammar. This evaluator accepts
37
+ * numeric literals, `attr.<code>` / `attr["<code>"]` reads, calls to the
38
+ * helpers below, parentheses and ordinary arithmetic — and rejects everything
39
+ * else outright. A formula this cannot evaluate is reported, not guessed at.
40
+ */
41
+
42
+ import { parse } from "acorn";
43
+
44
+ /**
45
+ * The HârnMaster Skill Base reduction, mirroring SoHL's `sb()` helper exactly.
46
+ *
47
+ * @param {...number} values - One or more attribute values.
48
+ * @returns {number} The reduced Skill Base.
49
+ * @throws {Error} If called with no arguments.
50
+ */
51
+ export function sb(...values) {
52
+ if (values.length === 0) {
53
+ throw new Error("sb() requires at least one attribute value");
54
+ }
55
+ const nums = values.map((v) => Number(v));
56
+ if (nums.length === 1) return nums[0];
57
+ if (nums.length === 2) {
58
+ const average = (nums[0] + nums[1]) / 2;
59
+ return nums[0] > nums[1] ? Math.ceil(average) : Math.floor(average);
60
+ }
61
+ const sum = nums.reduce((acc, n) => acc + n, 0);
62
+ return Math.round(sum / nums.length);
63
+ }
64
+
65
+ /**
66
+ * The helper functions a `skill.base` formula may call. SoHL's registry carries
67
+ * far more; only those a Skill Base formula has any use for are offered here,
68
+ * so an unsupported call fails loudly instead of evaluating to something
69
+ * plausible.
70
+ */
71
+ const HELPERS = Object.freeze({
72
+ sb,
73
+ min: (...v) => Math.min(...v.map(Number)),
74
+ max: (...v) => Math.max(...v.map(Number)),
75
+ floor: (v) => Math.floor(Number(v)),
76
+ ceil: (v) => Math.ceil(Number(v)),
77
+ round: (v) => Math.round(Number(v)),
78
+ abs: (v) => Math.abs(Number(v)),
79
+ });
80
+
81
+ /** Binary operators the evaluator honours. */
82
+ const BINARY = Object.freeze({
83
+ "+": (a, b) => a + b,
84
+ "-": (a, b) => a - b,
85
+ "*": (a, b) => a * b,
86
+ "/": (a, b) => a / b,
87
+ "%": (a, b) => a % b,
88
+ "**": (a, b) => a ** b,
89
+ });
90
+
91
+ /**
92
+ * Read `attr.<code>`, case-insensitively, defaulting to `0`.
93
+ *
94
+ * SoHL wraps its `attr` context in a Proxy so an attribute the actor does not
95
+ * have reads as `0` instead of throwing (`SkillLogic.buildAttrContext`). A
96
+ * plain lookup with the same fallback is equivalent for evaluation.
97
+ *
98
+ * @param {Record<string, number>} attrs - Attribute scores by shortcode.
99
+ * @param {string} code - The attribute shortcode referenced.
100
+ * @returns {number} The score, or `0` when the actor has no such attribute.
101
+ */
102
+ function readAttr(attrs, code) {
103
+ const value = attrs[String(code).toLowerCase()];
104
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
105
+ }
106
+
107
+ /**
108
+ * Evaluate one parsed expression node.
109
+ *
110
+ * @param {object} node - An acorn expression node.
111
+ * @param {Record<string, number>} attrs - Attribute scores by shortcode.
112
+ * @returns {number} The node's value.
113
+ * @throws {Error} On any construct outside the supported subset.
114
+ */
115
+ function evalNode(node, attrs) {
116
+ switch (node.type) {
117
+ case "Literal": {
118
+ if (typeof node.value !== "number") {
119
+ throw new Error(
120
+ `unsupported literal ${JSON.stringify(node.value)}`,
121
+ );
122
+ }
123
+ return node.value;
124
+ }
125
+ case "MemberExpression": {
126
+ // Only `attr.<code>` and `attr["<code>"]`. Any other object, and
127
+ // any computed key that is not a plain string, is out of scope.
128
+ if (node.object?.type !== "Identifier") {
129
+ throw new Error(
130
+ "only `attr.<code>` member reads are supported",
131
+ );
132
+ }
133
+ if (node.object.name !== "attr") {
134
+ throw new Error(
135
+ `unknown binding "${node.object.name}" — the skill.base scope binds only \`attr\``,
136
+ );
137
+ }
138
+ if (node.computed) {
139
+ if (
140
+ node.property.type !== "Literal" ||
141
+ typeof node.property.value !== "string"
142
+ ) {
143
+ throw new Error(
144
+ "a computed `attr[...]` read needs a literal string shortcode",
145
+ );
146
+ }
147
+ return readAttr(attrs, node.property.value);
148
+ }
149
+ return readAttr(attrs, node.property.name);
150
+ }
151
+ case "CallExpression": {
152
+ if (node.callee?.type !== "Identifier") {
153
+ throw new Error(
154
+ "only direct calls to a named helper are supported",
155
+ );
156
+ }
157
+ const helper = HELPERS[node.callee.name];
158
+ if (!helper) {
159
+ throw new Error(
160
+ `unknown helper "${node.callee.name}()" in a skill base formula`,
161
+ );
162
+ }
163
+ return helper(...node.arguments.map((a) => evalNode(a, attrs)));
164
+ }
165
+ case "BinaryExpression": {
166
+ const op = BINARY[node.operator];
167
+ if (!op) {
168
+ throw new Error(`unsupported operator "${node.operator}"`);
169
+ }
170
+ return op(evalNode(node.left, attrs), evalNode(node.right, attrs));
171
+ }
172
+ case "UnaryExpression": {
173
+ const value = evalNode(node.argument, attrs);
174
+ if (node.operator === "-") return -value;
175
+ if (node.operator === "+") return value;
176
+ throw new Error(`unsupported unary operator "${node.operator}"`);
177
+ }
178
+ case "ParenthesizedExpression":
179
+ return evalNode(node.expression, attrs);
180
+ default:
181
+ throw new Error(`unsupported expression node "${node.type}"`);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Evaluate a `skillBaseFormula` against an actor's attribute scores.
187
+ *
188
+ * Mirrors `SkillLogic.computeSkillBase`: an absent or blank formula is Skill
189
+ * Base `0` (not an error — a skill may legitimately have none), and the result
190
+ * is clamped to `>= 0`.
191
+ *
192
+ * @param {string|null|undefined} formula - The expression source.
193
+ * @param {Record<string, number>} attrs - Attribute scores by shortcode.
194
+ * @returns {{ value: number, error?: string }} The Skill Base, or the reason it
195
+ * could not be computed. On error `value` is `0`, matching the client.
196
+ */
197
+ export function evaluateSkillBase(formula, attrs = {}) {
198
+ const source = typeof formula === "string" ? formula.trim() : "";
199
+ if (!source) return { value: 0 };
200
+ let node;
201
+ try {
202
+ const program = parse(source, { ecmaVersion: 2022 });
203
+ if (
204
+ program.body.length !== 1 ||
205
+ program.body[0].type !== "ExpressionStatement"
206
+ ) {
207
+ return {
208
+ value: 0,
209
+ error: `skill base formula "${source}" is not a single expression`,
210
+ };
211
+ }
212
+ node = program.body[0].expression;
213
+ } catch {
214
+ return {
215
+ value: 0,
216
+ error: `skill base formula "${source}" could not be parsed`,
217
+ };
218
+ }
219
+ try {
220
+ const raw = evalNode(node, attrs);
221
+ if (!Number.isFinite(raw)) {
222
+ return {
223
+ value: 0,
224
+ error: `skill base formula "${source}" did not return a number (got ${String(raw)})`,
225
+ };
226
+ }
227
+ return { value: Math.max(0, raw) };
228
+ } catch (err) {
229
+ return {
230
+ value: 0,
231
+ error: `skill base formula "${source}": ${err.message}`,
232
+ };
233
+ }
234
+ }
235
+
236
+ /**
237
+ * The mastery level an unopened skill opens at, or `null` when it does not
238
+ * open at all.
239
+ *
240
+ * The client's rule (`SkillLogic.initialize`) is `Skill Base × initSkillMult`,
241
+ * applied only when `masteryLevelBase` is unset and the skill is on an actor.
242
+ * Two build-side refinements, neither of which changes what a client computes:
243
+ *
244
+ * - **A zero or absent `initSkillMult` stays `null`.** The multiplier is the
245
+ * switch for whether a skill opens at all, so writing the `0` the arithmetic
246
+ * yields would claim the skill opened at zero rather than that it never
247
+ * opened. `null` is what the field means by *not yet opened*, and the client
248
+ * arrives at the same place either way.
249
+ * - **A fractional product is an error, not a rounding.** `masteryLevelBase` is
250
+ * an integer field (`min: 0`), so a fractional value cannot be persisted
251
+ * honestly — where the client multiplies raw into a modifier and is free to
252
+ * carry the fraction, this is not. Reporting it follows
253
+ * `resolveSkillAptitudes`, which rejects a fractional modifier rather than
254
+ * rounding one.
255
+ *
256
+ * @param {object} system - The merged skill `system` block.
257
+ * @param {Record<string, number>} attrs - Attribute scores by shortcode.
258
+ * @returns {{ value: number|null, error?: string }} The opening mastery level,
259
+ * `null` to leave the field unset, or the reason it could not be computed.
260
+ */
261
+ export function openingMasteryLevel(system = {}, attrs = {}) {
262
+ const mult = Number(system.initSkillMult);
263
+ if (!Number.isFinite(mult) || mult <= 0) return { value: null };
264
+
265
+ const base = evaluateSkillBase(system.skillBaseFormula, attrs);
266
+ if (base.error) return { value: null, error: base.error };
267
+
268
+ const opened = base.value * mult;
269
+ if (!Number.isInteger(opened)) {
270
+ return {
271
+ value: null,
272
+ error:
273
+ `opening mastery level is ${opened} (skill base ${base.value} × ` +
274
+ `initSkillMult ${mult}), but masteryLevelBase is a whole number — ` +
275
+ `give the skill a multiplier that divides evenly, or state its ` +
276
+ `masteryLevelBase outright`,
277
+ };
278
+ }
279
+ return { value: opened };
280
+ }
@@ -426,9 +426,9 @@ export type ContentBuildConfigInput = {
426
426
  */
427
427
  rootDir: string;
428
428
  /**
429
- * Content package name — the value each
430
- * content note carries in its `package:`
431
- * frontmatter.
429
+ * Content package name — the address
430
+ * namespace every note in this
431
+ * repository is published under.
432
432
  */
433
433
  contentPackage: string;
434
434
  /**
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * The tallies one pass accumulates while walking the tree.
3
3
  *
4
+ * `declined` and `skippedOther` are deliberately separate numbers. A declined
5
+ * note is one this build **refused** — it names a package this repository does
6
+ * not compile — and it is an error; a skipped one legitimately belongs to
7
+ * another pass, and there are thousands of those. Folding the first into the
8
+ * second is what let a whole tree be filtered out in silence (#56).
9
+ *
4
10
  * @typedef {object} PassStats
5
11
  * @property {number} compiled - Notes that became a document.
6
12
  * @property {number} skippedDraft - Notes marked `draft: true`.
7
13
  * @property {number} skippedNoId - Notes with no `id`, where that is tolerated.
8
14
  * @property {number} skippedOther - Notes this pass does not claim.
15
+ * @property {number} declined - Notes refused because they declare another
16
+ * package. Counted as errors, never as skips.
9
17
  */
10
18
  /**
11
19
  * The shared walk → filter → expand → convert → build → write → count loop.
@@ -136,8 +144,9 @@ export class BasePackCompiler {
136
144
  /**
137
145
  * Whether this pass claims a note. **Required.**
138
146
  *
139
- * Called only for a note of the configured content package, so a subclass
140
- * decides on `type` alone.
147
+ * Called only for a note this build compiles every note in the tree
148
+ * belongs to the configured content package (#56) — so a subclass decides
149
+ * on `type` alone.
141
150
  *
142
151
  * @param {object} fm - The note's frontmatter.
143
152
  * @returns {boolean} True to compile it.
@@ -299,6 +308,12 @@ export class BasePackCompiler {
299
308
  }
300
309
  /**
301
310
  * The tallies one pass accumulates while walking the tree.
311
+ *
312
+ * `declined` and `skippedOther` are deliberately separate numbers. A declined
313
+ * note is one this build **refused** — it names a package this repository does
314
+ * not compile — and it is an error; a skipped one legitimately belongs to
315
+ * another pass, and there are thousands of those. Folding the first into the
316
+ * second is what let a whole tree be filtered out in silence (#56).
302
317
  */
303
318
  export type PassStats = {
304
319
  /**
@@ -317,4 +332,9 @@ export type PassStats = {
317
332
  * - Notes this pass does not claim.
318
333
  */
319
334
  skippedOther: number;
335
+ /**
336
+ * - Notes refused because they declare another
337
+ * package. Counted as errors, never as skips.
338
+ */
339
+ declined: number;
320
340
  };
@@ -1,10 +1,23 @@
1
1
  /**
2
- * The **content** package: the distribution unit a note declares in its
3
- * `package:` frontmatter. The pack compilers select their entries by it.
2
+ * The **content** package: the distribution unit this repository's notes belong
3
+ * to, and the **address namespace** every one of them is published under.
4
+ *
5
+ * It is the first segment of every canonical key (`sohl-skill-clmb`), the name
6
+ * of the link manifest this build emits (`sohl.json`), and the package a
7
+ * cross-package wikilink writes to reach one of these notes. So it is the
8
+ * repository's identity in the address space, not a switch — and never dead
9
+ * configuration, whatever else changes.
10
+ *
11
+ * It was also, until #56, a **selector**: a note declared the same value in its
12
+ * `package:` frontmatter and the compilers kept the ones that matched. Every
13
+ * content tree is single-package — each is single-sourced in the repository that
14
+ * ships it — so the field restated this constant once per note while a value
15
+ * that matched nothing filtered the whole tree out in silence. The field is
16
+ * being retired; the value stays, here, where it is declared once.
4
17
  *
5
18
  * Stable across compilation targets. If this content were ever compiled for a
6
- * second game system, its notes would still declare `package: sohl` — only the
7
- * Foundry package below would differ.
19
+ * second game system, it would still be published as `sohl` — only the Foundry
20
+ * package below would differ.
8
21
  *
9
22
  * An accessor rather than a hoisted constant, so that importing this module
10
23
  * needs no configuration (#2).
@@ -18,7 +31,7 @@ export function contentPackage(): string;
18
31
  * compendium UUID the compilers emit.
19
32
  *
20
33
  * Distinct from {@link contentPackage}, and equal to it only by coincidence
21
- * here: a note says `package: sohl` and its documents are addressed as
34
+ * here: a note is published under `sohl` and its documents are addressed as
22
35
  * `Compendium.sohl.<pack>.<Type>.<id>`. In `sohl-thalorna` the two differ
23
36
  * (`thalorna` vs `sohl-thalorna`), which is why they are separate values rather
24
37
  * than one — treating them as interchangeable is what #1498 was.
@@ -20,9 +20,9 @@ export function itemPackJsonDirs(config?: object): string[];
20
20
  * The passes that compiled nothing when they were expected to compile
21
21
  * something — a build failure, not a quiet no-op.
22
22
  *
23
- * A pack compiler selects its entries by the configured content package, so a
24
- * single wrong package id rejects every note in a perfectly good tree and every
25
- * pack ships blank while the build exits 0 (#1502). The empty-tree guard in
23
+ * A pack ships blank whenever every note in a full tree was rejected — by a
24
+ * `selects` that claims nothing, or a `pack:` that routes everything elsewhere
25
+ * and the build then exits 0 (#1502). The empty-tree guard in
26
26
  * {@link generatePacksJson} cannot see that: the tree is full, it is the
27
27
  * *output* that is empty.
28
28
  *
@@ -274,14 +274,17 @@ export function collectContentDocs(contentBase: string): Array<{
274
274
  * Expand the fenced `dataview` tables in one note's markdown, before wikilinks
275
275
  * are resolved — so a generated cell may itself be a wikilink.
276
276
  *
277
- * A table searches only notes of the source note's own `package`, so a SoHL
278
- * page never tabulates setting-package content (and vice versa).
277
+ * A table searches only notes of the source note's own package, so a SoHL page
278
+ * never tabulates setting-package content (and vice versa). Each candidate's
279
+ * package is **derived** rather than read out of its frontmatter: `package:` is
280
+ * optional, and comparing a declared value with an absent one would drop every
281
+ * unswept — or every swept — note from the table (#56).
279
282
  *
280
283
  * @param {string} body - The note's markdown body.
281
284
  * @param {object} ctx
282
285
  * @param {Array<object>} ctx.docs - From {@link collectContentDocs}.
283
286
  * @param {string} ctx.name - The note, for the error message.
284
- * @param {string} [ctx.pkg] - The source note's `package`.
287
+ * @param {string} [ctx.pkg] - The source note's package.
285
288
  * @param {object} [ctx.fm] - The source note's frontmatter, which is what a
286
289
  * query's `this` reads. Its entry in `docs` supplies the path as well.
287
290
  * @param {number} [ctx.bodyLine] - 1-based file line of the body's first line,
@@ -5,6 +5,7 @@ export * as contentTree from "./content-tree.mjs";
5
5
  export * as packConfig from "./pack-config.mjs";
6
6
  export * as packRouter from "./pack-router.mjs";
7
7
  export * as contentPackage from "./content-package.mjs";
8
+ export * as notePackage from "./note-package.mjs";
8
9
  export * as contentSlug from "./content-slug.mjs";
9
10
  export * as contentAddress from "./content-address.mjs";
10
11
  export * as foreignManifests from "./foreign-manifests.mjs";
@@ -122,8 +122,8 @@ export const DEFAULT_MACRO_IMG: "icons/svg/dice-target.svg";
122
122
  /**
123
123
  * Macros pack compiler.
124
124
  *
125
- * Walks the content tree and compiles every `package: sohl`, `type: macro`
126
- * note into one Macro document. The same note's documentation is compiled by
125
+ * Walks the content tree and compiles every `type: macro` note into one Macro
126
+ * document. The same note's documentation is compiled by
127
127
  * the journals pass; neither pass reads the other's output.
128
128
  */
129
129
  export class Macros extends BasePackCompiler {
@@ -39,9 +39,12 @@ export function entriesForNote(fm: object, name: string, address: string, body:
39
39
  *
40
40
  * Drafts are excluded because the site does not publish them, and an entry for
41
41
  * an unpublished page is exactly the dead link the manifest exists to prevent.
42
- * A note belonging to another content package is skipped for the same reason in
43
- * reverse: this build is not authoritative for it, and its own build says where
44
- * it lives.
42
+ *
43
+ * Every note in the tree is this package's note, whether or not it says so:
44
+ * `package:` is optional and merely has to agree (#56). A note naming a
45
+ * different package **throws** rather than being skipped — this build is not
46
+ * authoritative for it, and skipping it silently is how a whole tree came to be
47
+ * filtered out of a manifest that then claimed the package published nothing.
45
48
  *
46
49
  * A note that has no address is **reported, not guessed** — the finding carries
47
50
  * the file and the reason, so a caller can print it or fail on it. Inventing an
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The package a note belongs to.
3
+ *
4
+ * Non-validating: the answer for a note that declares nothing, and for one that
5
+ * declares the configured package, is the same value. A note declaring some
6
+ * *other* package is answered literally here rather than corrected — the
7
+ * compile pass reports that, once, through {@link assertNotePackage}.
8
+ *
9
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
10
+ * could not be parsed.
11
+ * @param {string} [configured] - The package this build compiles. Defaults to
12
+ * the configured `contentPackage`; passed explicitly by callers that already
13
+ * carry it in a context object, so a caller's configuration drives every read.
14
+ * @returns {string} The package.
15
+ */
16
+ export function notePackage(fm: object | null | undefined, configured?: string): string;
17
+ /**
18
+ * A note's frontmatter as a generated table searches it — its package present
19
+ * whether or not the note declares one.
20
+ *
21
+ * A `dataview` query resolves `package` out of frontmatter like any other
22
+ * field, so a collection note that scopes itself with `WHERE … and package =
23
+ * "sohl"` matches nothing once the field is deleted, and renders an **empty
24
+ * table** in silence. Deriving the value here keeps the two spellings
25
+ * equivalent, so a sweep that deletes the field is mechanical rather than a
26
+ * trap (#56) — and a query that never mentions `package` is unaffected either
27
+ * way.
28
+ *
29
+ * The declared value is left alone when there is one, so nothing about an
30
+ * unswept tree changes.
31
+ *
32
+ * @param {object|null|undefined} fm - Parsed frontmatter.
33
+ * @param {string} [configured] - The package this build compiles.
34
+ * @returns {object|null|undefined} The frontmatter itself when it declares a
35
+ * package, else a shallow copy carrying the derived one.
36
+ */
37
+ export function searchableFrontmatter(fm: object | null | undefined, configured?: string): object | null | undefined;
38
+ /**
39
+ * The package a note belongs to, refusing one that names another package.
40
+ *
41
+ * @param {object|null|undefined} fm - Parsed frontmatter.
42
+ * @param {object} [options] - Options.
43
+ * @param {string} [options.file] - The note's path, named in the message. Omit
44
+ * it where the caller emits through a diagnostic, which puts the locator at
45
+ * the start of the line already — repeating it prints the path twice.
46
+ * @param {string} [options.configured] - The package this build compiles.
47
+ * Defaults to the configured `contentPackage`.
48
+ * @returns {string} The package, which is always `configured`.
49
+ * @throws {Error} When the note declares a different package.
50
+ */
51
+ export function assertNotePackage(fm: object | null | undefined, { file, configured }?: {
52
+ file?: string | undefined;
53
+ configured?: string | undefined;
54
+ }): string;
@@ -46,8 +46,9 @@ export class PackRoutingError extends Error {
46
46
  /**
47
47
  * The frontmatter field a note declares its pack in.
48
48
  *
49
- * Deliberately close to `package:` and deliberately not the same word: a note's
50
- * `package:` says which *distribution* owns it, `pack:` which *compendium*
49
+ * Deliberately close to the retiring `package:` and deliberately not the same
50
+ * word: `package:` said which *distribution* owned a note now the
51
+ * repository's `contentPackage` (#56) — while `pack:` says which *compendium*
51
52
  * receives its document.
52
53
  */
53
54
  export const PACK_FIELD: "pack";
@@ -16,7 +16,9 @@ export function walkSiteTree(dir: string, skip?: readonly string[]): string[];
16
16
  * The content tree's pages, and what could not be addressed.
17
17
  *
18
18
  * @param {string} contentBase - Absolute path to the content tree.
19
- * @param {object} ctx - `{ packages, skipDirectories, mount, scheme }`.
19
+ * @param {object} ctx - `{ packages, contentPackage, skipDirectories, mount,
20
+ * scheme }`. `contentPackage` is the package a note that declares none
21
+ * belongs to.
20
22
  * @returns {{pages: object[], slugFindings: object[], fmLinkFindings: object[]}}
21
23
  */
22
24
  export function collectContentPages(contentBase: string, ctx: object): {
@@ -82,6 +84,16 @@ export function tableUniverse(pages: object[]): Map<string, object[]>;
82
84
  * redirect stub at each name. They are dropped, and this build emits no
83
85
  * redirects of its own.
84
86
  *
87
+ * A content page carries the package the build **derived**, whether or not the
88
+ * note declared one (#65). `package:` became optional in 3.3.0, so a swept tree
89
+ * declares none — and the note's frontmatter alone would then publish a page
90
+ * that does not say which package it belongs to. The emitted page is what a
91
+ * theme reads: `breadcrumbs.html` builds its middle crumb from
92
+ * `.Params.package`, so without it that crumb degrades from a linked, labelled
93
+ * section to a bare type slug. Writing the derived value keeps a page
94
+ * self-describing and makes sweeping the field out of a content tree
95
+ * output-preserving for a site as it already is for the packs.
96
+ *
85
97
  * @param {object} page - The page.
86
98
  * @param {object} options - `{ sections, readmeSections, decorate }`.
87
99
  * @returns {object} The frontmatter to write.
@@ -25,6 +25,33 @@ export class Actors extends BasePackCompiler {
25
25
  * entry plus one per `sohl.items` entry. `sohl.skills` is ignored.
26
26
  */
27
27
  buildEmbeddedItems(itemsMap: any, actorId: any, fm: any, ctx: any): any[];
28
+ /**
29
+ * Bake each unopened skill's opening mastery level into the document (#46).
30
+ *
31
+ * A skill whose `masteryLevelBase` is still null once the note's frontmatter
32
+ * has been merged onto the catalogue entry is *not yet opened*, and the
33
+ * client fills it in on import — `Skill Base × initSkillMult`, in
34
+ * `SkillLogic.initialize`. Computing it here instead leaves the compiled
35
+ * pack self-describing: what a being's skills open at is visible in the
36
+ * document, reviewable in a diff, and testable without standing up Foundry.
37
+ *
38
+ * This runs last because the Skill Base formula reads the actor's
39
+ * attributes, so every attribute item has to exist first. It only ever
40
+ * fills nulls — a skill that states a `masteryLevelBase`, whether from the
41
+ * catalogue or the note, keeps it untouched.
42
+ *
43
+ * **The scores used are the ones just written.** `SkillLogic` resolves
44
+ * `attr.<code>` to an attribute's *effective* score, after active effects;
45
+ * all this pass has is the `scoreBase` it set from `sohl.attributes`. For a
46
+ * compiled being carrying no attribute-altering effects the two agree,
47
+ * which is every being in content today. One that did carry such an effect
48
+ * would bake a Skill Base its client then disagrees with — that is the
49
+ * limit of doing this at build time, and the point to revisit if it bites.
50
+ *
51
+ * @param {object[]} items - The actor's embedded items, attributes included.
52
+ * @param {string} ctx - Diagnostic context (the actor's label).
53
+ */
54
+ openUnopenedSkills(items: object[], ctx: string): void;
28
55
  buildBeing(itemsMap: any, fm: any, body: any): {
29
56
  name: any;
30
57
  type: string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The HârnMaster Skill Base reduction, mirroring SoHL's `sb()` helper exactly.
3
+ *
4
+ * @param {...number} values - One or more attribute values.
5
+ * @returns {number} The reduced Skill Base.
6
+ * @throws {Error} If called with no arguments.
7
+ */
8
+ export function sb(...values: number[]): number;
9
+ /**
10
+ * Evaluate a `skillBaseFormula` against an actor's attribute scores.
11
+ *
12
+ * Mirrors `SkillLogic.computeSkillBase`: an absent or blank formula is Skill
13
+ * Base `0` (not an error — a skill may legitimately have none), and the result
14
+ * is clamped to `>= 0`.
15
+ *
16
+ * @param {string|null|undefined} formula - The expression source.
17
+ * @param {Record<string, number>} attrs - Attribute scores by shortcode.
18
+ * @returns {{ value: number, error?: string }} The Skill Base, or the reason it
19
+ * could not be computed. On error `value` is `0`, matching the client.
20
+ */
21
+ export function evaluateSkillBase(formula: string | null | undefined, attrs?: Record<string, number>): {
22
+ value: number;
23
+ error?: string;
24
+ };
25
+ /**
26
+ * The mastery level an unopened skill opens at, or `null` when it does not
27
+ * open at all.
28
+ *
29
+ * The client's rule (`SkillLogic.initialize`) is `Skill Base × initSkillMult`,
30
+ * applied only when `masteryLevelBase` is unset and the skill is on an actor.
31
+ * Two build-side refinements, neither of which changes what a client computes:
32
+ *
33
+ * - **A zero or absent `initSkillMult` stays `null`.** The multiplier is the
34
+ * switch for whether a skill opens at all, so writing the `0` the arithmetic
35
+ * yields would claim the skill opened at zero rather than that it never
36
+ * opened. `null` is what the field means by *not yet opened*, and the client
37
+ * arrives at the same place either way.
38
+ * - **A fractional product is an error, not a rounding.** `masteryLevelBase` is
39
+ * an integer field (`min: 0`), so a fractional value cannot be persisted
40
+ * honestly — where the client multiplies raw into a modifier and is free to
41
+ * carry the fraction, this is not. Reporting it follows
42
+ * `resolveSkillAptitudes`, which rejects a fractional modifier rather than
43
+ * rounding one.
44
+ *
45
+ * @param {object} system - The merged skill `system` block.
46
+ * @param {Record<string, number>} attrs - Attribute scores by shortcode.
47
+ * @returns {{ value: number|null, error?: string }} The opening mastery level,
48
+ * `null` to leave the field unset, or the reason it could not be computed.
49
+ */
50
+ export function openingMasteryLevel(system?: object, attrs?: Record<string, number>): {
51
+ value: number | null;
52
+ error?: string;
53
+ };