@heroiclands/package-build 9.0.0 → 10.0.1
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 +721 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +437 -7
- package/content-config.mjs +259 -28
- package/docs/content-format.md +1418 -0
- package/engine/address-charset.mjs +62 -0
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +194 -4
- package/engine/content-address.mjs +4 -4
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +132 -56
- package/engine/content-lint.mjs +8 -1
- package/engine/diagnostics.mjs +33 -0
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-spec.mjs +49 -43
- package/engine/frontmatter-lint.mjs +351 -28
- package/engine/generate.mjs +32 -4
- package/engine/helpers.mjs +41 -29
- package/engine/ids.mjs +19 -1
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -5
- package/engine/kb-manifest.mjs +36 -7
- package/engine/map-notes.mjs +34 -18
- package/engine/note-claims.mjs +383 -0
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +39 -22
- package/engine/pack-router.mjs +17 -6
- package/engine/prose-lint.mjs +55 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scenes.mjs +19 -1
- package/engine/schema-check.mjs +347 -3
- package/engine/site-build.mjs +1 -1
- package/engine/site-index.mjs +38 -21
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +112 -80
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +67 -51
- package/package.json +6 -2
- package/sohl/actors.mjs +249 -36
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +3 -0
- package/sohl/items.mjs +110 -14
- package/sohl/note-schemas.mjs +11 -7
- package/types/content-config.d.mts +48 -4
- package/types/engine/address-charset.d.mts +45 -0
- package/types/engine/alias-index.d.mts +122 -0
- package/types/engine/base-compiler.d.mts +132 -4
- package/types/engine/content-address.d.mts +2 -2
- package/types/engine/content-format-check.d.mts +163 -0
- package/types/engine/content-format.d.mts +101 -0
- package/types/engine/content-links.d.mts +16 -1
- package/types/engine/content-lint.d.mts +6 -0
- package/types/engine/diagnostics.d.mts +29 -0
- package/types/engine/document-subtypes.d.mts +233 -0
- package/types/engine/field-spec.d.mts +76 -23
- package/types/engine/frontmatter-lint.d.mts +47 -2
- package/types/engine/generate.d.mts +14 -1
- package/types/engine/helpers.d.mts +21 -13
- package/types/engine/ids.d.mts +10 -0
- package/types/engine/index.d.mts +5 -0
- package/types/engine/item-registry.d.mts +21 -2
- package/types/engine/kb-manifest.d.mts +35 -8
- package/types/engine/map-notes.d.mts +21 -11
- package/types/engine/note-claims.d.mts +113 -0
- package/types/engine/note-vocabulary.d.mts +251 -0
- package/types/engine/pack-config.d.mts +4 -3
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/prose-lint.d.mts +6 -2
- package/types/engine/retired-fields.d.mts +73 -2
- package/types/engine/schema-check.d.mts +182 -0
- package/types/engine/system-block.d.mts +281 -0
- package/types/engine/web-wikilinks.d.mts +23 -12
- package/types/engine/wikilink-syntax.d.mts +29 -0
- package/types/sohl/actors.d.mts +62 -6
- package/types/sohl/document-subtypes.d.mts +14 -0
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/items.d.mts +21 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The **closed** half of a note's frontmatter: the `data:` container, and the
|
|
16
|
+
* `subType` each note type declares (#128).
|
|
17
|
+
*
|
|
18
|
+
* A note's frontmatter has three regions, and only one of them is open. The
|
|
19
|
+
* **top level** describes the note as a published artefact, every key of it is
|
|
20
|
+
* copied into the generated web page, and an unrecognised key there is a Hugo
|
|
21
|
+
* or theme parameter this build has no standing to refuse. The **system
|
|
22
|
+
* blocks** describe the subject as one system's documents. Between them sits
|
|
23
|
+
* `data:` — the type-specific facts about the subject itself, system-agnostic,
|
|
24
|
+
* and closed.
|
|
25
|
+
*
|
|
26
|
+
* Closed is the whole point. Those facts previously sat at the top level, where
|
|
27
|
+
* the pass-through rule applied to them too, so a misspelled `wieght` became a
|
|
28
|
+
* theme parameter rather than a finding — indistinguishable, from the outside,
|
|
29
|
+
* from a weapon that simply weighs nothing. Under `data:` the same misspelling
|
|
30
|
+
* is an error naming the note and the key it was probably meant to be.
|
|
31
|
+
*
|
|
32
|
+
* **`subType` rides along, and stays at the top level.** It is not a `data:`
|
|
33
|
+
* key: a note's `(type, subType)` is what each system's map reads to derive a
|
|
34
|
+
* document type, so it describes the note rather than the subject. But it is
|
|
35
|
+
* the other per-type vocabulary the format closes, it is enumerated in the same
|
|
36
|
+
* `### type:` section of the specification that enumerates the `data:` keys,
|
|
37
|
+
* and keeping the two together means one entry per type rather than two
|
|
38
|
+
* registries free to disagree about which types exist.
|
|
39
|
+
*
|
|
40
|
+
* **This is note-format knowledge, so it lives in `engine/`.** `data:` holds
|
|
41
|
+
* what is true of the *thing* — a weapon's weight, an affliction's
|
|
42
|
+
* transmission — and is true of it whichever system is reading. What each
|
|
43
|
+
* system makes of that value is declared elsewhere, in that system's own half.
|
|
44
|
+
*
|
|
45
|
+
* **The type names here are today's**, which for four of them is not the name
|
|
46
|
+
* the specification uses: `armor`, `weapon`, `projectile` and `concoction` are
|
|
47
|
+
* still spelled `armorgear`, `weapongear`, `projectilegear` and
|
|
48
|
+
* `concoctiongear`, and a `map` is still one of `battlemap` / `localmap` /
|
|
49
|
+
* `regionalmap`. Those renames are a later slice (#78, #79), and declaring the
|
|
50
|
+
* vocabulary under a name no note may yet carry would make it unreachable. The
|
|
51
|
+
* specification's types with no name here at all — `place`, `scenario`,
|
|
52
|
+
* `lore`, `vehicle`, `armorlocation` — are likewise deferred: a note carrying
|
|
53
|
+
* one is already reported as a type no schema declares, which is the finding it
|
|
54
|
+
* deserves until the type exists.
|
|
55
|
+
*
|
|
56
|
+
* @module
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One `data:` key a note type may carry.
|
|
61
|
+
*
|
|
62
|
+
* A deliberate subset of {@link import("./field-spec.mjs").FieldSpec}: no `to`,
|
|
63
|
+
* because nothing here builds anything yet. Reading `data.*` through into a
|
|
64
|
+
* document's `system` block is the passthrough slice (#126), and claiming an
|
|
65
|
+
* emitted path this does not produce would be a lie in the one place a reader
|
|
66
|
+
* would trust it.
|
|
67
|
+
*
|
|
68
|
+
* @typedef {object} DataFieldSpec
|
|
69
|
+
* @property {string} name - The key under `data:`, dotted for a nested one
|
|
70
|
+
* (`charges.value`).
|
|
71
|
+
* @property {"string"|"number"|"boolean"|"list"|"map"} [kind] - The value's
|
|
72
|
+
* shape, for the lint. Absent means no claim is made about the value — which
|
|
73
|
+
* is the honest answer wherever the specification's stated shape and the
|
|
74
|
+
* shape notes are authored in today disagree.
|
|
75
|
+
* @property {string} [shape] - Human-readable shape, for a finding and for
|
|
76
|
+
* documentation.
|
|
77
|
+
* @property {string} describe - One line, for the author-facing reference.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What one note type declares.
|
|
82
|
+
*
|
|
83
|
+
* `subTypes` is three-valued, and the difference matters:
|
|
84
|
+
*
|
|
85
|
+
* - **omitted** — the type has no `subType` at all, and a note carrying one is
|
|
86
|
+
* a finding. A `weapon` is the deliberate case: SoHL distinguishes a
|
|
87
|
+
* weapon's uses by strike mode rather than by kind.
|
|
88
|
+
* - **`null`** — the type has a `subType` whose values the specification does
|
|
89
|
+
* not yet enumerate. Presence is permitted and the value is unchecked.
|
|
90
|
+
* - **a list** — the closed set of values, and anything else is a finding.
|
|
91
|
+
*
|
|
92
|
+
* @typedef {object} TypeVocabulary
|
|
93
|
+
* @property {readonly DataFieldSpec[]} data - The `data:` keys, closed.
|
|
94
|
+
* @property {readonly string[]|null} [subTypes] - The top-level `subType`
|
|
95
|
+
* values, as above.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/* --------------------------------------------------------------------- */
|
|
99
|
+
/* Shapes */
|
|
100
|
+
/* --------------------------------------------------------------------- */
|
|
101
|
+
|
|
102
|
+
/** A number. */
|
|
103
|
+
const NUM = Object.freeze({ shape: "number", kind: "number" });
|
|
104
|
+
|
|
105
|
+
/** A string, including the enumerated-value fields the format spells as one. */
|
|
106
|
+
const TEXT = Object.freeze({ shape: "string", kind: "string" });
|
|
107
|
+
|
|
108
|
+
/** A list. */
|
|
109
|
+
const LIST = Object.freeze({ shape: "list", kind: "list" });
|
|
110
|
+
|
|
111
|
+
/** A single wikilink, which is a string until it is resolved. */
|
|
112
|
+
const LINK = Object.freeze({ shape: "a wikilink", kind: "string" });
|
|
113
|
+
|
|
114
|
+
/** A list of wikilinks. */
|
|
115
|
+
const LINKS = Object.freeze({ shape: "list of wikilinks", kind: "list" });
|
|
116
|
+
|
|
117
|
+
/** Whatever the author wrote — declared, but with no claim about its shape. */
|
|
118
|
+
const ANY = Object.freeze({ shape: "as authored" });
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The template-priority key every document-producing type carries.
|
|
122
|
+
*
|
|
123
|
+
* Declared once and shared, rather than retyped in twenty tables where the one
|
|
124
|
+
* that was mistyped would be the one nobody noticed.
|
|
125
|
+
*
|
|
126
|
+
* @type {DataFieldSpec}
|
|
127
|
+
*/
|
|
128
|
+
const TEMPLATE_PRIORITY = Object.freeze({
|
|
129
|
+
name: "templatePriority",
|
|
130
|
+
...NUM,
|
|
131
|
+
describe: "Template priority; unset means the note is not a template.",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The four gear values every carried thing declares.
|
|
136
|
+
*
|
|
137
|
+
* @type {readonly DataFieldSpec[]}
|
|
138
|
+
*/
|
|
139
|
+
const GEAR = Object.freeze([
|
|
140
|
+
{ name: "weight", ...NUM, describe: "What the thing weighs." },
|
|
141
|
+
{ name: "value", ...NUM, describe: "What the thing is worth." },
|
|
142
|
+
{ name: "quality", ...NUM, describe: "How well it is made." },
|
|
143
|
+
{ name: "durability", ...NUM, describe: "How much wear it takes before it fails." },
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
/** Gear that is counted rather than carried singly. */
|
|
147
|
+
const QUANTITY = Object.freeze({
|
|
148
|
+
name: "quantity",
|
|
149
|
+
...NUM,
|
|
150
|
+
describe: "How many of the thing there are; one when unstated.",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The charges a mystery or mystical ability holds.
|
|
155
|
+
*
|
|
156
|
+
* @type {readonly DataFieldSpec[]}
|
|
157
|
+
*/
|
|
158
|
+
const CHARGES = Object.freeze([
|
|
159
|
+
{
|
|
160
|
+
name: "charges.value",
|
|
161
|
+
...NUM,
|
|
162
|
+
describe: "Charges available now; unset means charges are not used.",
|
|
163
|
+
},
|
|
164
|
+
{ name: "charges.max", ...NUM, describe: "Most charges it can hold; unset means no maximum." },
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
/* --------------------------------------------------------------------- */
|
|
168
|
+
/* The vocabulary */
|
|
169
|
+
/* --------------------------------------------------------------------- */
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Every note type this toolchain compiles, and the closed vocabulary it
|
|
173
|
+
* declares.
|
|
174
|
+
*
|
|
175
|
+
* Taken from the content-format specification, one `### type:` section per
|
|
176
|
+
* entry. Where the specification and the shape notes are authored in today
|
|
177
|
+
* disagree, the specification wins on the **name** — that is what a `data:`
|
|
178
|
+
* key will be called — and the disagreement is recorded on the field rather
|
|
179
|
+
* than resolved silently.
|
|
180
|
+
*
|
|
181
|
+
* @type {Readonly<Record<string, TypeVocabulary>>}
|
|
182
|
+
*/
|
|
183
|
+
/**
|
|
184
|
+
* The tags that **classify** a note, grouped by what they classify (#172).
|
|
185
|
+
*
|
|
186
|
+
* `tags:` lives at the open top level and most tags belong there: a theme, a
|
|
187
|
+
* region, a working state is the author's own and this build has no opinion
|
|
188
|
+
* about it. A classifying tag is different, because something queries it — a
|
|
189
|
+
* settlement tagged `village` appears in the list of villages and an untagged
|
|
190
|
+
* one does not, so `vilage` does not merely look wrong, it removes the note from
|
|
191
|
+
* an index while the index still renders a table that looks complete.
|
|
192
|
+
*
|
|
193
|
+
* **This list is not a closed set.** An unrecognised tag is legal, because the
|
|
194
|
+
* region is open; what is reported is a **near miss** — a tag close enough to a
|
|
195
|
+
* declared one to be a typo of it.
|
|
196
|
+
*
|
|
197
|
+
* **Each group names the types it applies to**, and that scope is what makes the
|
|
198
|
+
* check sound rather than noisy. A place's kinds are only a place's: `azravan`
|
|
199
|
+
* on a faith, `barter` on an economy note and `secret` on three lore notes all
|
|
200
|
+
* sit within a typo's distance of `caravan`, `border` and `sacred`, and not one
|
|
201
|
+
* is a mistake. Checked against every group at once the rule was wrong on every
|
|
202
|
+
* note it touched; scoped to the type it is wrong on none. `types: null` is a
|
|
203
|
+
* group any note may carry.
|
|
204
|
+
*
|
|
205
|
+
* Kind and character are separate groups because one slot could not hold both: a
|
|
206
|
+
* fishing village is a `village` that is `fishing`, and the single-valued field
|
|
207
|
+
* this replaced had to spell it `Fishing Village` as a value of its own.
|
|
208
|
+
*/
|
|
209
|
+
export const DECLARED_TAGS = Object.freeze({
|
|
210
|
+
/** What a place *is*. */
|
|
211
|
+
placeKind: Object.freeze({
|
|
212
|
+
types: ["place"],
|
|
213
|
+
tags: Object.freeze([
|
|
214
|
+
"city",
|
|
215
|
+
"city-state",
|
|
216
|
+
"town",
|
|
217
|
+
"village",
|
|
218
|
+
"settlement",
|
|
219
|
+
"port",
|
|
220
|
+
"fortress",
|
|
221
|
+
"citadel",
|
|
222
|
+
"castle",
|
|
223
|
+
"stronghold",
|
|
224
|
+
"garrison",
|
|
225
|
+
"camp",
|
|
226
|
+
"oasis",
|
|
227
|
+
"waypoint",
|
|
228
|
+
"post",
|
|
229
|
+
"precinct",
|
|
230
|
+
"district",
|
|
231
|
+
"necropolis",
|
|
232
|
+
"hall",
|
|
233
|
+
"capital",
|
|
234
|
+
]),
|
|
235
|
+
}),
|
|
236
|
+
/** What a place is known for. */
|
|
237
|
+
placeCharacter: Object.freeze({
|
|
238
|
+
types: ["place"],
|
|
239
|
+
tags: Object.freeze([
|
|
240
|
+
"fortified",
|
|
241
|
+
"temple",
|
|
242
|
+
"market",
|
|
243
|
+
"trading",
|
|
244
|
+
"merchant",
|
|
245
|
+
"mining",
|
|
246
|
+
"fishing",
|
|
247
|
+
"naval",
|
|
248
|
+
"military",
|
|
249
|
+
"imperial",
|
|
250
|
+
"provincial",
|
|
251
|
+
"coastal",
|
|
252
|
+
"river",
|
|
253
|
+
"lakeside",
|
|
254
|
+
"hill",
|
|
255
|
+
"mountain",
|
|
256
|
+
"valley",
|
|
257
|
+
"forest",
|
|
258
|
+
"woodland",
|
|
259
|
+
"inland",
|
|
260
|
+
"island",
|
|
261
|
+
"frontier",
|
|
262
|
+
"border",
|
|
263
|
+
"craft",
|
|
264
|
+
"caravan",
|
|
265
|
+
"pilgrimage",
|
|
266
|
+
"holy",
|
|
267
|
+
"sacred",
|
|
268
|
+
"free",
|
|
269
|
+
]),
|
|
270
|
+
}),
|
|
271
|
+
/** A place's scale, where the subtype does not distinguish it. */
|
|
272
|
+
placeScale: Object.freeze({ types: ["place"], tags: Object.freeze(["continent"]) }),
|
|
273
|
+
/** Which kind of body a being belongs to — a station rather than a rank. */
|
|
274
|
+
beingStation: Object.freeze({
|
|
275
|
+
types: ["being"],
|
|
276
|
+
tags: Object.freeze([
|
|
277
|
+
"tradesfolk",
|
|
278
|
+
"common-folk",
|
|
279
|
+
"soldiery",
|
|
280
|
+
"administration",
|
|
281
|
+
"clergy",
|
|
282
|
+
"mages",
|
|
283
|
+
"underworld",
|
|
284
|
+
"dependents",
|
|
285
|
+
"guilded",
|
|
286
|
+
"unguilded",
|
|
287
|
+
]),
|
|
288
|
+
}),
|
|
289
|
+
/** A note's working state, which any note may carry. */
|
|
290
|
+
state: Object.freeze({ types: null, tags: Object.freeze(["draft"]) }),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The declared tags a note of this type may carry, flattened.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} type - The note's type.
|
|
297
|
+
* @param {object} [groups] - The grouped declaration.
|
|
298
|
+
* @returns {readonly string[]} The tags, in declaration order.
|
|
299
|
+
*/
|
|
300
|
+
export function declaredTags(type, groups = DECLARED_TAGS) {
|
|
301
|
+
const applies = Object.values(groups).filter((g) => !g.types || g.types.includes(type));
|
|
302
|
+
return Object.freeze(applies.flatMap((g) => g.tags));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export const NOTE_VOCABULARY = Object.freeze({
|
|
306
|
+
/* ----- actors --------------------------------------------------- */
|
|
307
|
+
|
|
308
|
+
being: Object.freeze({
|
|
309
|
+
// Derived from the note's `(type, subType)` by each system's map, which
|
|
310
|
+
// lands with #79. Declared open until it does, because inventing the
|
|
311
|
+
// values here would put a second, weaker answer beside the real one.
|
|
312
|
+
subTypes: null,
|
|
313
|
+
data: Object.freeze([
|
|
314
|
+
{ name: "portrait", ...TEXT, describe: "Path to the portrait image." },
|
|
315
|
+
TEMPLATE_PRIORITY,
|
|
316
|
+
{ name: "archetypes", ...LIST, describe: "Archetypal behaviours the being fits." },
|
|
317
|
+
{ name: "occupation", ...TEXT, describe: "What the being does for a living." },
|
|
318
|
+
{ name: "stations", ...LINKS, describe: "Stations the being holds." },
|
|
319
|
+
{ name: "peoples", ...LINKS, describe: "Peoples the being belongs to." },
|
|
320
|
+
{ name: "homes", ...LINKS, describe: "Places the being calls home." },
|
|
321
|
+
{
|
|
322
|
+
name: "affiliations",
|
|
323
|
+
...LINKS,
|
|
324
|
+
describe: "Affiliations the being belongs to — traditions, polities, and the rest.",
|
|
325
|
+
},
|
|
326
|
+
{ name: "gender", ...TEXT, describe: "`male`, `female` or `other`." },
|
|
327
|
+
{ name: "species", ...LINK, describe: "The being's species, as a lore note." },
|
|
328
|
+
{ name: "age", ...NUM, describe: "Age in years." },
|
|
329
|
+
{ name: "birthday", ...TEXT, describe: "Date of birth, `YYYY/MM/DD`." },
|
|
330
|
+
{ name: "height", ...NUM, describe: "Height in metres." },
|
|
331
|
+
{ name: "weight", ...NUM, describe: "Weight in kilograms." },
|
|
332
|
+
{
|
|
333
|
+
name: "frame",
|
|
334
|
+
...TEXT,
|
|
335
|
+
describe: "Relative frame — `scant`, `light`, `medium`, `large` or `massive`.",
|
|
336
|
+
},
|
|
337
|
+
{ name: "appearance.eye_color", ...TEXT, describe: "Eye colour." },
|
|
338
|
+
{ name: "appearance.hair_color", ...TEXT, describe: "Hair colour." },
|
|
339
|
+
{ name: "appearance.skin_color", ...TEXT, describe: "Skin colour." },
|
|
340
|
+
{ name: "appearance.complexion", ...TEXT, describe: "Complexion." },
|
|
341
|
+
{
|
|
342
|
+
name: "appearance.extra_features",
|
|
343
|
+
...LIST,
|
|
344
|
+
describe: "Anything else a stranger would notice.",
|
|
345
|
+
},
|
|
346
|
+
]),
|
|
347
|
+
}),
|
|
348
|
+
|
|
349
|
+
/* ----- items ---------------------------------------------------- */
|
|
350
|
+
|
|
351
|
+
affiliation: Object.freeze({
|
|
352
|
+
subTypes: Object.freeze([
|
|
353
|
+
"guild",
|
|
354
|
+
"order",
|
|
355
|
+
"polity",
|
|
356
|
+
"faithtradition",
|
|
357
|
+
"arcanetradition",
|
|
358
|
+
"spirittradition",
|
|
359
|
+
"lineage",
|
|
360
|
+
"venture",
|
|
361
|
+
"criminal",
|
|
362
|
+
"governmental",
|
|
363
|
+
"fellowship",
|
|
364
|
+
]),
|
|
365
|
+
data: Object.freeze([
|
|
366
|
+
TEMPLATE_PRIORITY,
|
|
367
|
+
{
|
|
368
|
+
name: "demonym",
|
|
369
|
+
...TEXT,
|
|
370
|
+
describe: "What one member is called — a Vylarian.",
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "governance.model",
|
|
374
|
+
...TEXT,
|
|
375
|
+
describe: "How the affiliation is governed, where it is.",
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
name: "governance.summary",
|
|
379
|
+
...TEXT,
|
|
380
|
+
describe: "A sentence on how the governance actually works.",
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "governance.ranks",
|
|
384
|
+
...LIST,
|
|
385
|
+
describe: "The ladder of ranks the body confers — level, title, description.",
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "governance.offices",
|
|
389
|
+
...ANY,
|
|
390
|
+
describe: "The named offices it appoints, each with what the office does.",
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "commonSkills",
|
|
394
|
+
...LINKS,
|
|
395
|
+
describe: "Skills common among members — languages first among them.",
|
|
396
|
+
},
|
|
397
|
+
{ name: "seat", ...LINK, describe: "Where the affiliation's authority sits." },
|
|
398
|
+
{ name: "domains", ...LINKS, describe: "Places over which it holds sway." },
|
|
399
|
+
{ name: "population", ...NUM, describe: "How many people it counts." },
|
|
400
|
+
{
|
|
401
|
+
name: "economy",
|
|
402
|
+
...LINKS,
|
|
403
|
+
describe: "What its economic life runs on — currencies, banking bodies, goods.",
|
|
404
|
+
},
|
|
405
|
+
{ name: "peoples", ...LINKS, describe: "Peoples associated with it." },
|
|
406
|
+
{ name: "parents", ...LINKS, describe: "Affiliations it is subordinate to." },
|
|
407
|
+
{
|
|
408
|
+
name: "relations",
|
|
409
|
+
...ANY,
|
|
410
|
+
describe: "Standing with other affiliations — aligned, unaligned, rival, nemesis.",
|
|
411
|
+
},
|
|
412
|
+
]),
|
|
413
|
+
}),
|
|
414
|
+
|
|
415
|
+
affliction: Object.freeze({
|
|
416
|
+
subTypes: Object.freeze(["disease", "poisontoxin", "maladiction"]),
|
|
417
|
+
data: Object.freeze([
|
|
418
|
+
TEMPLATE_PRIORITY,
|
|
419
|
+
{ name: "transmission", ...TEXT, describe: "How it passes from one host to another." },
|
|
420
|
+
{
|
|
421
|
+
name: "outcome",
|
|
422
|
+
...TEXT,
|
|
423
|
+
describe: "Where it ends once it has run its course — `death` or `cured`.",
|
|
424
|
+
},
|
|
425
|
+
{ name: "healingRate", ...NUM, describe: "How readily a healing test goes well." },
|
|
426
|
+
{ name: "contagionIndex", ...NUM, describe: "How contagious it is." },
|
|
427
|
+
{
|
|
428
|
+
name: "outcomeTraumas",
|
|
429
|
+
...TEXT,
|
|
430
|
+
describe: "Expression returning the traumas recovery leaves behind.",
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
name: "onsetDurationFormula",
|
|
434
|
+
...TEXT,
|
|
435
|
+
describe: "Roll formula for the delay between contraction and onset.",
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: "healingCheckDurationFormula",
|
|
439
|
+
...TEXT,
|
|
440
|
+
describe: "Roll formula for the interval between healing checks.",
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "resolutionDurationFormula",
|
|
444
|
+
...TEXT,
|
|
445
|
+
describe: "Roll formula for the time from onset to resolution.",
|
|
446
|
+
},
|
|
447
|
+
]),
|
|
448
|
+
}),
|
|
449
|
+
|
|
450
|
+
armorgear: Object.freeze({
|
|
451
|
+
// Quantity is always one, so the specification refuses the key rather
|
|
452
|
+
// than defaulting it.
|
|
453
|
+
data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR]),
|
|
454
|
+
}),
|
|
455
|
+
|
|
456
|
+
attribute: Object.freeze({
|
|
457
|
+
data: Object.freeze([TEMPLATE_PRIORITY]),
|
|
458
|
+
}),
|
|
459
|
+
|
|
460
|
+
concoctiongear: Object.freeze({
|
|
461
|
+
subTypes: Object.freeze(["mundane", "exotic", "elixir"]),
|
|
462
|
+
data: Object.freeze([
|
|
463
|
+
TEMPLATE_PRIORITY,
|
|
464
|
+
...GEAR,
|
|
465
|
+
QUANTITY,
|
|
466
|
+
{
|
|
467
|
+
name: "potency",
|
|
468
|
+
...TEXT,
|
|
469
|
+
describe: "Potency — `na`, `mild`, `strong` or `great`.",
|
|
470
|
+
},
|
|
471
|
+
{ name: "strength", ...NUM, describe: "Strength; the higher, the stronger." },
|
|
472
|
+
]),
|
|
473
|
+
}),
|
|
474
|
+
|
|
475
|
+
containergear: Object.freeze({
|
|
476
|
+
data: Object.freeze([
|
|
477
|
+
TEMPLATE_PRIORITY,
|
|
478
|
+
...GEAR,
|
|
479
|
+
{ name: "capacity", ...NUM, describe: "How much it holds." },
|
|
480
|
+
]),
|
|
481
|
+
}),
|
|
482
|
+
|
|
483
|
+
miscgear: Object.freeze({
|
|
484
|
+
data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
|
|
485
|
+
}),
|
|
486
|
+
|
|
487
|
+
mystery: Object.freeze({
|
|
488
|
+
subTypes: Object.freeze(["boon", "boost", "fate", "grace", "birthsign", "other", "piety"]),
|
|
489
|
+
data: Object.freeze([
|
|
490
|
+
TEMPLATE_PRIORITY,
|
|
491
|
+
{ name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
|
|
492
|
+
{
|
|
493
|
+
name: "assocAffiliation",
|
|
494
|
+
...LINK,
|
|
495
|
+
describe: "The affiliation it is associated with.",
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "skillAptitudes",
|
|
499
|
+
...ANY,
|
|
500
|
+
describe:
|
|
501
|
+
"Bonuses and penalties, each naming a skill or a `subType:<skill-subtype>`.",
|
|
502
|
+
},
|
|
503
|
+
{ name: "level", ...NUM, describe: "The magnitude of the mystery." },
|
|
504
|
+
...CHARGES,
|
|
505
|
+
]),
|
|
506
|
+
}),
|
|
507
|
+
|
|
508
|
+
mysticalability: Object.freeze({
|
|
509
|
+
subTypes: Object.freeze([
|
|
510
|
+
"spiritrite",
|
|
511
|
+
"spiritaction",
|
|
512
|
+
"spiritpower",
|
|
513
|
+
"ritualaction",
|
|
514
|
+
"divineincantation",
|
|
515
|
+
"arcaneincantation",
|
|
516
|
+
"arcanetalent",
|
|
517
|
+
"spirittalent",
|
|
518
|
+
"alchemy",
|
|
519
|
+
"divination",
|
|
520
|
+
]),
|
|
521
|
+
data: Object.freeze([
|
|
522
|
+
TEMPLATE_PRIORITY,
|
|
523
|
+
{ name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
|
|
524
|
+
{
|
|
525
|
+
name: "assocAffiliation",
|
|
526
|
+
...LINK,
|
|
527
|
+
describe: "The affiliation it is associated with.",
|
|
528
|
+
},
|
|
529
|
+
{ name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
|
|
530
|
+
{ name: "level", ...NUM, describe: "The magnitude of the ability." },
|
|
531
|
+
...CHARGES,
|
|
532
|
+
]),
|
|
533
|
+
}),
|
|
534
|
+
|
|
535
|
+
projectilegear: Object.freeze({
|
|
536
|
+
subTypes: Object.freeze(["none", "arrow", "bolt", "bullet", "dart", "other"]),
|
|
537
|
+
data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
|
|
538
|
+
}),
|
|
539
|
+
|
|
540
|
+
skill: Object.freeze({
|
|
541
|
+
subTypes: Object.freeze([
|
|
542
|
+
"social",
|
|
543
|
+
"nature",
|
|
544
|
+
"craft",
|
|
545
|
+
"lore",
|
|
546
|
+
"language",
|
|
547
|
+
"script",
|
|
548
|
+
"mystical",
|
|
549
|
+
"physical",
|
|
550
|
+
"combat",
|
|
551
|
+
"combattechnique",
|
|
552
|
+
]),
|
|
553
|
+
data: Object.freeze([
|
|
554
|
+
TEMPLATE_PRIORITY,
|
|
555
|
+
{ name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
|
|
556
|
+
{ name: "parentSkill", ...LINK, describe: "The skill this one specialises." },
|
|
557
|
+
]),
|
|
558
|
+
}),
|
|
559
|
+
|
|
560
|
+
trauma: Object.freeze({
|
|
561
|
+
subTypes: Object.freeze([
|
|
562
|
+
"injury",
|
|
563
|
+
"fear",
|
|
564
|
+
"morale",
|
|
565
|
+
"pall",
|
|
566
|
+
"psycond",
|
|
567
|
+
"physcond",
|
|
568
|
+
"auralshock",
|
|
569
|
+
"fatigue",
|
|
570
|
+
"infection",
|
|
571
|
+
"shock",
|
|
572
|
+
"coma",
|
|
573
|
+
]),
|
|
574
|
+
data: Object.freeze([TEMPLATE_PRIORITY]),
|
|
575
|
+
}),
|
|
576
|
+
|
|
577
|
+
weapongear: Object.freeze({
|
|
578
|
+
// No `subTypes`, deliberately: SoHL distinguishes a weapon's uses with
|
|
579
|
+
// strike modes rather than by kind, and HM3's document type follows
|
|
580
|
+
// from which of those a note describes.
|
|
581
|
+
data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR]),
|
|
582
|
+
}),
|
|
583
|
+
|
|
584
|
+
/* ----- core documents ------------------------------------------- */
|
|
585
|
+
|
|
586
|
+
doc: Object.freeze({
|
|
587
|
+
subTypes: Object.freeze(["rules", "user-guide", "reference"]),
|
|
588
|
+
data: Object.freeze([]),
|
|
589
|
+
}),
|
|
590
|
+
|
|
591
|
+
macro: Object.freeze({ data: Object.freeze([]) }),
|
|
592
|
+
|
|
593
|
+
homepage: Object.freeze({ data: Object.freeze([]) }),
|
|
594
|
+
|
|
595
|
+
map: Object.freeze({
|
|
596
|
+
// One type, three subTypes: they differ only in the canvas defaults
|
|
597
|
+
// derived for them, which is precisely what a subType decides (#174).
|
|
598
|
+
subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
|
|
599
|
+
data: Object.freeze([
|
|
600
|
+
// The specification spells this `img`, matching every other
|
|
601
|
+
// note type, while the map compiler reads `image` from
|
|
602
|
+
// `sohl:` today. It says outright that one of the two has
|
|
603
|
+
// to move; the container takes the specification's name,
|
|
604
|
+
// and moving the authored key is the migration's business.
|
|
605
|
+
{ name: "img", ...TEXT, describe: "Path to the map art." },
|
|
606
|
+
{
|
|
607
|
+
name: "dimensions",
|
|
608
|
+
...LIST,
|
|
609
|
+
describe: "`[width, height]` in whole pixels — the art's own size.",
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "pxPerGrid",
|
|
613
|
+
...NUM,
|
|
614
|
+
describe: "Whole pixels per grid square; must match the art.",
|
|
615
|
+
},
|
|
616
|
+
{ name: "navName", ...TEXT, describe: "Short name for the navigation bar." },
|
|
617
|
+
{ name: "levelName", ...TEXT, describe: "Name of the embedded level." },
|
|
618
|
+
{
|
|
619
|
+
name: "backgroundColor",
|
|
620
|
+
...TEXT,
|
|
621
|
+
describe: "Colour shown where the art does not reach.",
|
|
622
|
+
},
|
|
623
|
+
{ name: "overlay", ...TEXT, describe: "Path to the foreground art." },
|
|
624
|
+
// Geometry carries no `kind`. The specification lists each
|
|
625
|
+
// as a sequence while the notes authoring them today write
|
|
626
|
+
// a map keyed by name, and a lint has no business picking
|
|
627
|
+
// the winner of a disagreement the format has not settled.
|
|
628
|
+
{ name: "walls", ...ANY, describe: "Wall segments." },
|
|
629
|
+
{ name: "doors", ...ANY, describe: "Doors." },
|
|
630
|
+
{ name: "lights", ...ANY, describe: "Light sources." },
|
|
631
|
+
{ name: "tiles", ...ANY, describe: "Tiles." },
|
|
632
|
+
{ name: "sounds", ...ANY, describe: "Ambient sounds." },
|
|
633
|
+
{ name: "regions", ...ANY, describe: "Regions and their behaviours." },
|
|
634
|
+
{
|
|
635
|
+
name: "notes",
|
|
636
|
+
...ANY,
|
|
637
|
+
describe: "Map pins, each a grid location and an anchor in this note's own body.",
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
name: "place",
|
|
641
|
+
...TEXT,
|
|
642
|
+
describe:
|
|
643
|
+
"The place this map depicts. Named here and not on the place, " +
|
|
644
|
+
"because a place has several maps and a map depicts one place.",
|
|
645
|
+
},
|
|
646
|
+
]),
|
|
647
|
+
}),
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The `data:` keys a note type may carry.
|
|
652
|
+
*
|
|
653
|
+
* @param {string} type - The note's `type`.
|
|
654
|
+
* @param {Readonly<Record<string, TypeVocabulary>>} [vocabulary] - The registry
|
|
655
|
+
* to read, defaulting to {@link NOTE_VOCABULARY}.
|
|
656
|
+
* @returns {readonly DataFieldSpec[]|undefined} The declaration, or `undefined`
|
|
657
|
+
* when the type declares none — which is not the same as declaring an empty
|
|
658
|
+
* one, and is why the lint makes no claim rather than refusing every key.
|
|
659
|
+
*/
|
|
660
|
+
export function dataFields(type, vocabulary = NOTE_VOCABULARY) {
|
|
661
|
+
return vocabulary?.[type]?.data;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The `subType` values a note type declares.
|
|
666
|
+
*
|
|
667
|
+
* @param {string} type - The note's `type`.
|
|
668
|
+
* @param {Readonly<Record<string, TypeVocabulary>>} [vocabulary] - The registry
|
|
669
|
+
* to read, defaulting to {@link NOTE_VOCABULARY}.
|
|
670
|
+
* @returns {readonly string[]|null|undefined} The closed set; `null` when the
|
|
671
|
+
* type has a `subType` whose values are not yet enumerated; `undefined` when
|
|
672
|
+
* it has no `subType` at all — see {@link TypeVocabulary}.
|
|
673
|
+
*/
|
|
674
|
+
export function subTypes(type, vocabulary = NOTE_VOCABULARY) {
|
|
675
|
+
const entry = vocabulary?.[type];
|
|
676
|
+
if (!entry || !Object.hasOwn(entry, "subTypes")) return undefined;
|
|
677
|
+
return entry.subTypes;
|
|
678
|
+
}
|