@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.
- package/CHANGELOG.md +101 -0
- package/CONTENT.md +29 -4
- package/content-config.mjs +3 -3
- package/engine/base-compiler.mjs +40 -4
- package/engine/content-links.mjs +15 -15
- package/engine/content-package.mjs +18 -5
- package/engine/field-reference.mjs +3 -1
- package/engine/generate.mjs +10 -10
- package/engine/helpers.mjs +14 -6
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +2 -4
- package/engine/macros.mjs +2 -2
- package/engine/manifest-emit.mjs +10 -5
- package/engine/note-package.mjs +126 -0
- package/engine/pack-router.mjs +3 -2
- package/engine/scenes.mjs +6 -2
- package/engine/site-build.mjs +26 -7
- package/engine/site-index.mjs +8 -1
- package/package.json +1 -1
- package/sohl/actors.mjs +55 -0
- package/sohl/skill-base.mjs +280 -0
- package/types/content-config.d.mts +3 -3
- package/types/engine/base-compiler.d.mts +22 -2
- package/types/engine/content-package.d.mts +18 -5
- package/types/engine/generate.d.mts +3 -3
- package/types/engine/helpers.d.mts +6 -3
- package/types/engine/index.d.mts +1 -0
- package/types/engine/macros.d.mts +2 -2
- package/types/engine/manifest-emit.d.mts +6 -3
- package/types/engine/note-package.d.mts +54 -0
- package/types/engine/pack-router.d.mts +3 -2
- package/types/engine/site-build.d.mts +3 -1
- package/types/sohl/actors.d.mts +27 -0
- package/types/sohl/skill-base.d.mts +53 -0
|
@@ -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
|
+
}
|
package/engine/pack-router.mjs
CHANGED
|
@@ -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
|
|
73
|
-
* `package:`
|
|
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 {
|
|
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
|
-
|
|
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) &&
|
package/engine/site-build.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
347
|
+
const pkg = p.pkg;
|
|
336
348
|
if (!byPackage.has(pkg)) byPackage.set(pkg, []);
|
|
337
349
|
byPackage.get(pkg).push({
|
|
338
|
-
|
|
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.
|
|
474
|
+
docs: universe.get(page.pkg) ?? [],
|
|
461
475
|
linkable,
|
|
462
476
|
source: src,
|
|
463
|
-
self: {
|
|
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,
|
package/engine/site-index.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
430
|
-
*
|
|
431
|
-
*
|
|
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
|
/**
|