@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.
- package/CHANGELOG.md +180 -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 +47 -8
- package/engine/site-index.mjs +8 -1
- package/package.json +1 -1
- package/sohl/actors.mjs +55 -0
- package/sohl/item-fields.mjs +0 -8
- 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 +13 -1
- package/types/sohl/actors.d.mts +27 -0
- package/types/sohl/skill-base.d.mts +53 -0
package/engine/manifest-emit.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { canonicalKey, writeManifests } from "./kb-manifest.mjs";
|
|
|
54
54
|
import { walkMarkdownTree } from "./helpers.mjs";
|
|
55
55
|
import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
|
|
56
56
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
57
|
+
import { assertNotePackage } from "./note-package.mjs";
|
|
57
58
|
import { journalPageId, splitPages } from "./journals.mjs";
|
|
58
59
|
import { routerFor } from "./pack-router.mjs";
|
|
59
60
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
@@ -194,9 +195,12 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
194
195
|
*
|
|
195
196
|
* Drafts are excluded because the site does not publish them, and an entry for
|
|
196
197
|
* an unpublished page is exactly the dead link the manifest exists to prevent.
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
198
|
+
*
|
|
199
|
+
* Every note in the tree is this package's note, whether or not it says so:
|
|
200
|
+
* `package:` is optional and merely has to agree (#56). A note naming a
|
|
201
|
+
* different package **throws** rather than being skipped — this build is not
|
|
202
|
+
* authoritative for it, and skipping it silently is how a whole tree came to be
|
|
203
|
+
* filtered out of a manifest that then claimed the package published nothing.
|
|
200
204
|
*
|
|
201
205
|
* A note that has no address is **reported, not guessed** — the finding carries
|
|
202
206
|
* the file and the reason, so a caller can print it or fail on it. Inventing an
|
|
@@ -220,11 +224,12 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
220
224
|
contentBase,
|
|
221
225
|
{ skipDirectories: ctx.skipDirectories },
|
|
222
226
|
)) {
|
|
223
|
-
if (!fm
|
|
227
|
+
if (!fm) continue;
|
|
228
|
+
const rel = path.relative(contentBase, absPath);
|
|
229
|
+
assertNotePackage(fm, { file: rel, configured: ctx.contentPackage });
|
|
224
230
|
if (fm.draft === true) continue;
|
|
225
231
|
if (!fm.type || !fm.shortcode) continue;
|
|
226
232
|
|
|
227
|
-
const rel = path.relative(contentBase, absPath);
|
|
228
233
|
const base = path.basename(absPath);
|
|
229
234
|
const name = fm.name?.full ?? path.basename(absPath, ".md");
|
|
230
235
|
|
|
@@ -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,
|
|
@@ -353,6 +367,16 @@ export function tableUniverse(pages) {
|
|
|
353
367
|
* redirect stub at each name. They are dropped, and this build emits no
|
|
354
368
|
* redirects of its own.
|
|
355
369
|
*
|
|
370
|
+
* A content page carries the package the build **derived**, whether or not the
|
|
371
|
+
* note declared one (#65). `package:` became optional in 3.3.0, so a swept tree
|
|
372
|
+
* declares none — and the note's frontmatter alone would then publish a page
|
|
373
|
+
* that does not say which package it belongs to. The emitted page is what a
|
|
374
|
+
* theme reads: `breadcrumbs.html` builds its middle crumb from
|
|
375
|
+
* `.Params.package`, so without it that crumb degrades from a linked, labelled
|
|
376
|
+
* section to a bare type slug. Writing the derived value keeps a page
|
|
377
|
+
* self-describing and makes sweeping the field out of a content tree
|
|
378
|
+
* output-preserving for a site as it already is for the packs.
|
|
379
|
+
*
|
|
356
380
|
* @param {object} page - The page.
|
|
357
381
|
* @param {object} options - `{ sections, readmeSections, decorate }`.
|
|
358
382
|
* @returns {object} The frontmatter to write.
|
|
@@ -361,7 +385,17 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
361
385
|
const { fm, name, slug, sec, isReadme } = page;
|
|
362
386
|
let data;
|
|
363
387
|
if (page.kind === "content") {
|
|
364
|
-
data = {
|
|
388
|
+
data = {
|
|
389
|
+
...fm,
|
|
390
|
+
// Spread after the note's own frontmatter, so a note that declares
|
|
391
|
+
// the field keeps its authored position and value and an unswept
|
|
392
|
+
// tree emits byte-identically. Guarded because `package: undefined`
|
|
393
|
+
// is not a value YAML can carry.
|
|
394
|
+
...(page.pkg ? { package: page.pkg } : {}),
|
|
395
|
+
slug,
|
|
396
|
+
title: fm.title ?? name,
|
|
397
|
+
kbfolder: page.folder,
|
|
398
|
+
};
|
|
365
399
|
if (decorate) decorate(data, page);
|
|
366
400
|
if (isReadme) {
|
|
367
401
|
const meta = readmeSections[sec];
|
|
@@ -457,10 +491,13 @@ export function renderPages(pages, options) {
|
|
|
457
491
|
let body = page.body;
|
|
458
492
|
if (page.kind === "content") {
|
|
459
493
|
const { markdown, errors } = expandContentTables(body, {
|
|
460
|
-
docs: universe.get(page.
|
|
494
|
+
docs: universe.get(page.pkg) ?? [],
|
|
461
495
|
linkable,
|
|
462
496
|
source: src,
|
|
463
|
-
self: {
|
|
497
|
+
self: {
|
|
498
|
+
fm: searchableFrontmatter(page.fm, page.pkg),
|
|
499
|
+
path: page.relPath,
|
|
500
|
+
},
|
|
464
501
|
});
|
|
465
502
|
tableErrors.push(...errors);
|
|
466
503
|
body = markdown;
|
|
@@ -697,6 +734,8 @@ export function buildSite({ config, outRoot } = {}) {
|
|
|
697
734
|
|
|
698
735
|
const ctx = {
|
|
699
736
|
packages,
|
|
737
|
+
// What a note that declares no `package:` belongs to (#56).
|
|
738
|
+
contentPackage: resolved.contentPackage,
|
|
700
739
|
skipDirectories: resolved.skipDirectories,
|
|
701
740
|
mount,
|
|
702
741
|
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.4.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;
|
package/sohl/item-fields.mjs
CHANGED
|
@@ -598,14 +598,6 @@ export const ITEM_FIELDS = Object.freeze({
|
|
|
598
598
|
describe:
|
|
599
599
|
"Shortcode of the affiliation whose standing confers the ability — a religion, school, or ancestor/totem/spirit.",
|
|
600
600
|
},
|
|
601
|
-
{
|
|
602
|
-
name: "assocMysteryCode",
|
|
603
|
-
to: "assocMysteryCode",
|
|
604
|
-
ref: "mystery",
|
|
605
|
-
...AS_AUTHORED,
|
|
606
|
-
default: "",
|
|
607
|
-
describe: "Shortcode of the mystery the ability draws on.",
|
|
608
|
-
},
|
|
609
601
|
{
|
|
610
602
|
name: "masteryLevelBase",
|
|
611
603
|
to: "masteryLevelBase",
|