@heroiclands/package-build 3.2.0 → 3.3.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,126 @@
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
+ * Which content package a note belongs to.
16
+ *
17
+ * **It is the repository's configured `contentPackage`.** A content tree holds
18
+ * exactly one package's notes — every package is single-sourced in the
19
+ * repository that ships it — so the package is a property of the *repository*,
20
+ * not of the note.
21
+ *
22
+ * It used to be a property of the note, and a **selector**: the compile loop
23
+ * read `package:` out of frontmatter and skipped anything that did not match
24
+ * the configured value. That is the defect this module exists to remove
25
+ * (#56). The skip was silent and it was bucketed as "belongs to another pass",
26
+ * so a tree whose notes named a package no configuration answered to compiled
27
+ * **zero notes and exited 0** — which is exactly the state the un-migrated
28
+ * `hm-loc-*` / `hm-adv-*` repositories are in today.
29
+ *
30
+ * So the field is being retired, in three steps, of which this is the first:
31
+ *
32
+ * 1. **Optional here.** An absent `package:` is normal and the note compiles; a
33
+ * present one is accepted while it agrees, and is a loud, named error when
34
+ * it does not. Non-breaking, so a consumer adopts it before changing a note.
35
+ * 2. **Swept** out of every content tree in the org, on this version.
36
+ * 3. **Rejected outright**, as a major, once the sweeps have merged.
37
+ *
38
+ * The distinction between the two functions here is which question is being
39
+ * asked. {@link assertNotePackage} is for a site that used to *select* — it
40
+ * answers "may this build compile this note", and a disagreement is a finding.
41
+ * {@link notePackage} is for a site that *derives an address* — it answers
42
+ * "which package's namespace does this address sit in", where a disagreement
43
+ * has already been reported by the compile pass and repeating it would print
44
+ * the same fault twice.
45
+ *
46
+ * @module
47
+ */
48
+
49
+ import { contentPackage } from "./content-package.mjs";
50
+
51
+ /**
52
+ * The package a note belongs to.
53
+ *
54
+ * Non-validating: the answer for a note that declares nothing, and for one that
55
+ * declares the configured package, is the same value. A note declaring some
56
+ * *other* package is answered literally here rather than corrected — the
57
+ * compile pass reports that, once, through {@link assertNotePackage}.
58
+ *
59
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
60
+ * could not be parsed.
61
+ * @param {string} [configured] - The package this build compiles. Defaults to
62
+ * the configured `contentPackage`; passed explicitly by callers that already
63
+ * carry it in a context object, so a caller's configuration drives every read.
64
+ * @returns {string} The package.
65
+ */
66
+ export function notePackage(fm, configured) {
67
+ const declared = fm?.package;
68
+ // A blank is a declaration of nothing, not a package named "".
69
+ if (declared != null && declared !== "") return declared;
70
+ // Resolved only when it is needed, so a caller holding a note that declares
71
+ // one never touches the configuration (#2).
72
+ return configured ?? contentPackage();
73
+ }
74
+
75
+ /**
76
+ * A note's frontmatter as a generated table searches it — its package present
77
+ * whether or not the note declares one.
78
+ *
79
+ * A `dataview` query resolves `package` out of frontmatter like any other
80
+ * field, so a collection note that scopes itself with `WHERE … and package =
81
+ * "sohl"` matches nothing once the field is deleted, and renders an **empty
82
+ * table** in silence. Deriving the value here keeps the two spellings
83
+ * equivalent, so a sweep that deletes the field is mechanical rather than a
84
+ * trap (#56) — and a query that never mentions `package` is unaffected either
85
+ * way.
86
+ *
87
+ * The declared value is left alone when there is one, so nothing about an
88
+ * unswept tree changes.
89
+ *
90
+ * @param {object|null|undefined} fm - Parsed frontmatter.
91
+ * @param {string} [configured] - The package this build compiles.
92
+ * @returns {object|null|undefined} The frontmatter itself when it declares a
93
+ * package, else a shallow copy carrying the derived one.
94
+ */
95
+ export function searchableFrontmatter(fm, configured) {
96
+ if (!fm || typeof fm !== "object") return fm;
97
+ if (fm.package != null && fm.package !== "") return fm;
98
+ return { ...fm, package: notePackage(fm, configured) };
99
+ }
100
+
101
+ /**
102
+ * The package a note belongs to, refusing one that names another package.
103
+ *
104
+ * @param {object|null|undefined} fm - Parsed frontmatter.
105
+ * @param {object} [options] - Options.
106
+ * @param {string} [options.file] - The note's path, named in the message. Omit
107
+ * it where the caller emits through a diagnostic, which puts the locator at
108
+ * the start of the line already — repeating it prints the path twice.
109
+ * @param {string} [options.configured] - The package this build compiles.
110
+ * Defaults to the configured `contentPackage`.
111
+ * @returns {string} The package, which is always `configured`.
112
+ * @throws {Error} When the note declares a different package.
113
+ */
114
+ export function assertNotePackage(fm, { file, configured } = {}) {
115
+ const target = configured ?? contentPackage();
116
+ const pkg = notePackage(fm, target);
117
+ if (pkg === target) return target;
118
+ throw new Error(
119
+ `note declares \`package: ${pkg}\`, but this repository compiles ` +
120
+ `"${target}"` +
121
+ (file ? ` — ${file}` : "") +
122
+ `. A note's package is the configured \`contentPackage\`, so the ` +
123
+ `field is redundant and is being retired: delete it, or correct ` +
124
+ `\`contentPackage\` in package-build.config.yaml.`,
125
+ );
126
+ }
@@ -69,8 +69,9 @@ export class PackRoutingError extends Error {
69
69
  /**
70
70
  * The frontmatter field a note declares its pack in.
71
71
  *
72
- * Deliberately close to `package:` and deliberately not the same word: a note's
73
- * `package:` says which *distribution* owns it, `pack:` which *compendium*
72
+ * Deliberately close to the retiring `package:` and deliberately not the same
73
+ * word: `package:` said which *distribution* owned a note now the
74
+ * repository's `contentPackage` (#56) — while `pack:` says which *compendium*
74
75
  * receives its document.
75
76
  */
76
77
  export const PACK_FIELD = "pack";
package/engine/scenes.mjs CHANGED
@@ -63,7 +63,7 @@ import { BasePackCompiler } from "./base-compiler.mjs";
63
63
  import { buildJournalEntry, splitPages, journalPageId } from "./journals.mjs";
64
64
  import { compendiumUuid, makeId, packForType } from "./ids.mjs";
65
65
  import { packRouter } from "./pack-router.mjs";
66
- import { contentPackage, foundryPackageId } from "./content-package.mjs";
66
+ import { foundryPackageId } from "./content-package.mjs";
67
67
  import { itemDocEntryId } from "./item-docs.mjs";
68
68
  import {
69
69
  behaviorDocId,
@@ -192,7 +192,11 @@ export class Scenes extends BasePackCompiler {
192
192
  for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
193
193
  this.contentBase,
194
194
  )) {
195
- if (!fm || fm.package !== contentPackage() || !fm.id) continue;
195
+ // No package test: every note in the tree is this package's, and
196
+ // this pass's own walk — the shared compile loop — is where a note
197
+ // declaring another one is reported, once (#56). Repeating the
198
+ // check here would either double the diagnostic or throw past it.
199
+ if (!fm || !fm.id) continue;
196
200
  if (
197
201
  fm.shortcode &&
198
202
  Array.isArray(fm.effects) &&
@@ -62,6 +62,7 @@ import {
62
62
  } from "./foreign-manifests.mjs";
63
63
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
64
64
  import { loadPackConfig } from "./pack-config.mjs";
65
+ import { notePackage, searchableFrontmatter } from "./note-package.mjs";
65
66
 
66
67
  const require = createRequire(import.meta.url);
67
68
 
@@ -117,7 +118,9 @@ function readNote(file) {
117
118
  * The content tree's pages, and what could not be addressed.
118
119
  *
119
120
  * @param {string} contentBase - Absolute path to the content tree.
120
- * @param {object} ctx - `{ packages, skipDirectories, mount, scheme }`.
121
+ * @param {object} ctx - `{ packages, contentPackage, skipDirectories, mount,
122
+ * scheme }`. `contentPackage` is the package a note that declares none
123
+ * belongs to.
121
124
  * @returns {{pages: object[], slugFindings: object[], fmLinkFindings: object[]}}
122
125
  */
123
126
  export function collectContentPages(contentBase, ctx) {
@@ -129,7 +132,12 @@ export function collectContentPages(contentBase, ctx) {
129
132
  const note = readNote(file);
130
133
  if (!note) continue;
131
134
  const { fm, body } = note;
132
- if (!ctx.packages.has(fm.package) || !fm.type) continue;
135
+ // Derived rather than read: `package:` is optional, and a note that
136
+ // declares nothing belongs to the package this repository compiles
137
+ // (#56). Once the field is retired outright this collapses to
138
+ // `ctx.contentPackage` and the set membership becomes a formality.
139
+ const pkg = notePackage(fm, ctx.contentPackage);
140
+ if (!ctx.packages.has(pkg) || !fm.type) continue;
133
141
 
134
142
  for (const hit of frontmatterWikilinks(fm)) {
135
143
  fmLinkFindings.push({ file, ...hit });
@@ -154,6 +162,10 @@ export function collectContentPages(contentBase, ctx) {
154
162
  pages.push({
155
163
  kind: "content",
156
164
  fm,
165
+ // The page's package, resolved once here so every consumer — the
166
+ // index's canonical keys, the table universe, the local-package set
167
+ // — reads one derived value instead of frontmatter (#56).
168
+ pkg,
157
169
  body,
158
170
  name,
159
171
  slug,
@@ -287,7 +299,7 @@ export function siteGates(pages, findings, { manifestDir }) {
287
299
  // and that is only known once the tree is walked — reading it from a
288
300
  // configured list instead silently discarded the manifest of any package
289
301
  // the list named but the tree did not contain.
290
- const localPackages = new Set(content.map((p) => p.fm.package));
302
+ const localPackages = new Set(content.map((p) => p.pkg));
291
303
  const foreign = loadForeignManifests(manifestDir, localPackages);
292
304
  out.foreign = foreign;
293
305
  if (foreign.stale.length) {
@@ -332,10 +344,12 @@ export function tableUniverse(pages) {
332
344
  const byPackage = new Map();
333
345
  for (const p of pages) {
334
346
  if (p.kind !== "content") continue;
335
- const pkg = p.fm.package;
347
+ const pkg = p.pkg;
336
348
  if (!byPackage.has(pkg)) byPackage.set(pkg, []);
337
349
  byPackage.get(pkg).push({
338
- fm: p.fm,
350
+ // Package present however the note spells it — see
351
+ // {@link searchableFrontmatter} (#56).
352
+ fm: searchableFrontmatter(p.fm, pkg),
339
353
  path: p.relPath,
340
354
  tld: p.tld,
341
355
  folder: p.folder,
@@ -457,10 +471,13 @@ export function renderPages(pages, options) {
457
471
  let body = page.body;
458
472
  if (page.kind === "content") {
459
473
  const { markdown, errors } = expandContentTables(body, {
460
- docs: universe.get(page.fm.package) ?? [],
474
+ docs: universe.get(page.pkg) ?? [],
461
475
  linkable,
462
476
  source: src,
463
- self: { fm: page.fm, path: page.relPath },
477
+ self: {
478
+ fm: searchableFrontmatter(page.fm, page.pkg),
479
+ path: page.relPath,
480
+ },
464
481
  });
465
482
  tableErrors.push(...errors);
466
483
  body = markdown;
@@ -697,6 +714,8 @@ export function buildSite({ config, outRoot } = {}) {
697
714
 
698
715
  const ctx = {
699
716
  packages,
717
+ // What a note that declares no `package:` belongs to (#56).
718
+ contentPackage: resolved.contentPackage,
700
719
  skipDirectories: resolved.skipDirectories,
701
720
  mount,
702
721
  scheme,
@@ -53,6 +53,7 @@ import path from "node:path";
53
53
 
54
54
  import { canonicalKey, readCanonicalKey } from "./kb-manifest.mjs";
55
55
  import { hasDocEntry } from "./item-docs.mjs";
56
+ import { notePackage } from "./note-package.mjs";
56
57
 
57
58
  /**
58
59
  * One page the site will publish, as the index needs to see it.
@@ -238,7 +239,13 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
238
239
  // stays because a bare `[[skill-lang]]` defaults to the citing
239
240
  // note's own package and must keep resolving unchanged; the
240
241
  // canonical form is what cross-package links use (#1499).
241
- index.set(canonicalKey(e.fm.package, type, shortcode), value);
242
+ // The page's package is derived — the site collection resolves it
243
+ // and records it as `pkg` — rather than read out of frontmatter,
244
+ // where `package:` is optional and on its way out (#56).
245
+ index.set(
246
+ canonicalKey(e.pkg ?? notePackage(e.fm), type, shortcode),
247
+ value,
248
+ );
242
249
  // In Foundry an item and its documentation are two documents, so
243
250
  // `skill/wpnc` and `docskill/wpnc` are two UUIDs (#1362). Here the
244
251
  // item note renders as one page which *is* its documentation, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
package/sohl/actors.mjs CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  md,
56
56
  } from "../engine/helpers.mjs";
57
57
  import { emitDiagnostic } from "../engine/diagnostics.mjs";
58
+ import { openingMasteryLevel } from "./skill-base.mjs";
58
59
  import { BasePackCompiler } from "../engine/base-compiler.mjs";
59
60
  import { contentPackage } from "../engine/content-package.mjs";
60
61
 
@@ -498,9 +499,63 @@ export class Actors extends BasePackCompiler {
498
499
  });
499
500
  }
500
501
 
502
+ this.openUnopenedSkills(items, ctx);
501
503
  return items;
502
504
  }
503
505
 
506
+ /**
507
+ * Bake each unopened skill's opening mastery level into the document (#46).
508
+ *
509
+ * A skill whose `masteryLevelBase` is still null once the note's frontmatter
510
+ * has been merged onto the catalogue entry is *not yet opened*, and the
511
+ * client fills it in on import — `Skill Base × initSkillMult`, in
512
+ * `SkillLogic.initialize`. Computing it here instead leaves the compiled
513
+ * pack self-describing: what a being's skills open at is visible in the
514
+ * document, reviewable in a diff, and testable without standing up Foundry.
515
+ *
516
+ * This runs last because the Skill Base formula reads the actor's
517
+ * attributes, so every attribute item has to exist first. It only ever
518
+ * fills nulls — a skill that states a `masteryLevelBase`, whether from the
519
+ * catalogue or the note, keeps it untouched.
520
+ *
521
+ * **The scores used are the ones just written.** `SkillLogic` resolves
522
+ * `attr.<code>` to an attribute's *effective* score, after active effects;
523
+ * all this pass has is the `scoreBase` it set from `sohl.attributes`. For a
524
+ * compiled being carrying no attribute-altering effects the two agree,
525
+ * which is every being in content today. One that did carry such an effect
526
+ * would bake a Skill Base its client then disagrees with — that is the
527
+ * limit of doing this at build time, and the point to revisit if it bites.
528
+ *
529
+ * @param {object[]} items - The actor's embedded items, attributes included.
530
+ * @param {string} ctx - Diagnostic context (the actor's label).
531
+ */
532
+ openUnopenedSkills(items, ctx) {
533
+ const skills = items.filter(
534
+ (item) =>
535
+ item.type === "skill" &&
536
+ item.system &&
537
+ item.system.masteryLevelBase == null,
538
+ );
539
+ if (!skills.length) return;
540
+
541
+ const attrs = {};
542
+ for (const item of items) {
543
+ if (item.type !== "attribute") continue;
544
+ const code = item.system?.shortcode?.toLowerCase();
545
+ if (code) attrs[code] = Number(item.system.scoreBase) || 0;
546
+ }
547
+
548
+ for (const skill of skills) {
549
+ const { value, error } = openingMasteryLevel(skill.system, attrs);
550
+ if (error) {
551
+ this.noteError(`${ctx}: skill "${skill.name}": ${error}`);
552
+ this.errorCount++;
553
+ continue;
554
+ }
555
+ if (value !== null) skill.system.masteryLevelBase = value;
556
+ }
557
+ }
558
+
504
559
  buildBeing(itemsMap, fm, body) {
505
560
  const name = resolveName(fm);
506
561
  const id = fm.id;
@@ -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
  /**