@heroiclands/package-build 0.6.1 → 3.0.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.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +58 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,867 @@
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 `sohl:` frontmatter vocabulary of every SoHL item type.
16
+ *
17
+ * This is the authority for what an item note may write, and — because
18
+ * {@link buildFromFields} turns each list into the builder that runs — it is
19
+ * the same authority the compiler obeys. A field that is not here is not
20
+ * emitted; a description here is a description of the thing that executes
21
+ * (#22). The per-type authoring reference on the knowledgebase is generated
22
+ * from this file.
23
+ *
24
+ * **SoHL type vocabulary, so it lives in `sohl/`.** `engine/` holds the
25
+ * machinery — the declaration primitives, the coercions, the builder factory —
26
+ * because a consuming package declares *its own* types the same way. Moving
27
+ * this list into `engine/` would hand an adventure module a vocabulary for
28
+ * types it does not have.
29
+ *
30
+ * **A leaf, like the builders it feeds.** It imports the declaration
31
+ * primitives and the frontmatter readers, and never the resolved
32
+ * configuration: reading configuration back out is what closes the cycle the
33
+ * item-builders module note warns about.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import {
39
+ AS_AUTHORED,
40
+ BLANK_IS_DEFAULT,
41
+ BLANK_IS_NULL,
42
+ BOOLEAN,
43
+ NULLABLE_COUNT,
44
+ NULLABLE_NUMBER,
45
+ NUMBER,
46
+ STRING,
47
+ } from "../engine/field-spec.mjs";
48
+ import {
49
+ parseValueDesc,
50
+ requireSubType,
51
+ resolveCharges,
52
+ resolveRelation,
53
+ resolveSkillAptitudes,
54
+ sohlField,
55
+ } from "../engine/frontmatter.mjs";
56
+
57
+ /* --------------------------------------------------------------------- */
58
+ /* SoHL-specific coercions */
59
+ /* --------------------------------------------------------------------- */
60
+
61
+ /**
62
+ * The item's kind within its type — mandatory, and never defaulted.
63
+ *
64
+ * `requireSubType` re-reads the frontmatter rather than taking the raw value,
65
+ * so the error names the note and says what to add.
66
+ */
67
+ const SUB_TYPE = Object.freeze({
68
+ shape: "string",
69
+ read: (_raw, { fm }) => requireSubType(fm),
70
+ });
71
+
72
+ /** A charge pool, read from `charges.value` / `charges.max` together. */
73
+ const CHARGES = Object.freeze({
74
+ shape: "`{value, max}`, both whole numbers or unset",
75
+ read: (_raw, { fm }) => resolveCharges(fm),
76
+ });
77
+
78
+ /** Label/threshold pairs, authored as `"Label: max"` strings or as objects. */
79
+ const VALUE_DESC = Object.freeze({
80
+ shape: "list of `Label: max` strings, or `{label, maxValue}` objects",
81
+ read: (raw) => parseValueDesc(raw),
82
+ });
83
+
84
+ /** Armour facings, each naming a body location and the side it covers. */
85
+ const FACING = Object.freeze({
86
+ shape: "list of `{location, side}`",
87
+ read: (raw) =>
88
+ (raw || []).map((entry) => ({
89
+ location: String(entry.location ?? ""),
90
+ side: String(entry.side ?? "all"),
91
+ })),
92
+ });
93
+
94
+ /**
95
+ * Name the note in an error the way its author will recognise it — by title,
96
+ * falling back to the shortcode.
97
+ *
98
+ * @param {object} fm - The note's frontmatter.
99
+ * @param {string} type - The item type, for the leading phrase.
100
+ * @returns {string} A context string for a thrown message.
101
+ */
102
+ function noteContext(fm, type) {
103
+ return `${type} "${fm?.name?.full ?? fm?.shortcode ?? "?"}"`;
104
+ }
105
+
106
+ /** Aptitude weights per skill selector, validated as whole numbers. */
107
+ const SKILL_APTITUDES = Object.freeze({
108
+ shape: "map of skill selector → whole number",
109
+ read: (_raw, { fm }) =>
110
+ resolveSkillAptitudes(fm, noteContext(fm, "mystery")),
111
+ });
112
+
113
+ /** Standings toward other affiliations, validated against the closed list. */
114
+ const RELATION = Object.freeze({
115
+ shape: "map of affiliation shortcode → standing",
116
+ read: (_raw, { fm }) => resolveRelation(fm, noteContext(fm, "affiliation")),
117
+ });
118
+
119
+ /**
120
+ * Every element must carry a non-blank `shortcode`, unique on the weapon — the
121
+ * shortcode is the mode's identity. The list is otherwise emitted verbatim.
122
+ */
123
+ const STRIKE_MODES = Object.freeze({
124
+ shape: "list of strike modes, each with a unique `shortcode`",
125
+ read: (raw) => {
126
+ if (!Array.isArray(raw)) return [];
127
+ const seen = new Set();
128
+ for (const { shortcode } of raw) {
129
+ if (!shortcode) {
130
+ throw new Error(
131
+ "weapongear strikeModes array element requires a 'shortcode'",
132
+ );
133
+ }
134
+ if (seen.has(shortcode)) {
135
+ throw new Error(
136
+ `weapongear has duplicate strike-mode shortcode "${shortcode}"`,
137
+ );
138
+ }
139
+ seen.add(shortcode);
140
+ }
141
+ return raw;
142
+ },
143
+ });
144
+
145
+ /**
146
+ * A projectile's impact die, read wherever the default of another impact field
147
+ * depends on it.
148
+ *
149
+ * @param {object} fm - The note's frontmatter.
150
+ * @returns {number} The die size, `0` when the projectile declares none.
151
+ */
152
+ function impactDie(fm) {
153
+ return Number(sohlField(fm, "impact.die", 0)) || 0;
154
+ }
155
+
156
+ /** A strike mode discriminated by `type`, mandatory on a combat technique. */
157
+ const STRIKE_MODE = Object.freeze({
158
+ shape: '`{type: "melee" | "missile", …}`',
159
+ read: (raw) => {
160
+ if (!raw || typeof raw !== "object" || !raw.type) {
161
+ throw new Error(
162
+ `combattechnique skill requires sohl.strikeMode with a 'type' discriminator ("melee" or "missile")`,
163
+ );
164
+ }
165
+ return raw;
166
+ },
167
+ });
168
+
169
+ /* --------------------------------------------------------------------- */
170
+ /* Shared groups */
171
+ /* --------------------------------------------------------------------- */
172
+
173
+ /**
174
+ * The fields every `*gear` type carries — the physical properties of a thing
175
+ * you can pick up, plus the possession state a compendium copy always starts
176
+ * in.
177
+ *
178
+ * @type {readonly import("../engine/field-spec.mjs").FieldSpec[]}
179
+ */
180
+ const GEAR_COMMON = Object.freeze([
181
+ {
182
+ to: "quantity",
183
+ value: 1,
184
+ describe: "How many the stack holds. A compendium article ships one.",
185
+ },
186
+ {
187
+ name: "weight",
188
+ to: "weightBase",
189
+ ...AS_AUTHORED,
190
+ kind: "number",
191
+ default: 0,
192
+ describe: "Weight of one, in pounds.",
193
+ },
194
+ {
195
+ name: "value",
196
+ to: "valueBase",
197
+ ...AS_AUTHORED,
198
+ kind: "number",
199
+ default: 0,
200
+ describe: "Worth of one, in pence.",
201
+ },
202
+ {
203
+ name: "quality",
204
+ to: "qualityBase",
205
+ ...AS_AUTHORED,
206
+ kind: "number",
207
+ default: 0,
208
+ describe: "Craftsmanship, as a modifier to what the article does.",
209
+ },
210
+ {
211
+ name: "durability",
212
+ to: "durabilityBase",
213
+ ...AS_AUTHORED,
214
+ kind: "number",
215
+ default: 0,
216
+ describe: "How much punishment the article takes before it fails.",
217
+ },
218
+ {
219
+ to: "sharedWithCohortIds",
220
+ value: () => [],
221
+ describe:
222
+ "Cohorts sharing the article. Possession state, never authored.",
223
+ },
224
+ {
225
+ to: "containerId",
226
+ value: null,
227
+ describe: "The container holding it. Possession state, never authored.",
228
+ },
229
+ {
230
+ to: "isCarried",
231
+ value: true,
232
+ describe: "Whether it is being carried. Possession state.",
233
+ },
234
+ {
235
+ to: "isEquipped",
236
+ value: false,
237
+ describe: "Whether it is equipped. Possession state.",
238
+ },
239
+ ]);
240
+
241
+ /* --------------------------------------------------------------------- */
242
+ /* Per-type declarations */
243
+ /* --------------------------------------------------------------------- */
244
+
245
+ /**
246
+ * Every item type's frontmatter vocabulary, in the order the `system` block
247
+ * emits it.
248
+ *
249
+ * @type {Readonly<Record<string, readonly import("../engine/field-spec.mjs").FieldSpec[]>>}
250
+ */
251
+ export const ITEM_FIELDS = Object.freeze({
252
+ affiliation: Object.freeze([
253
+ {
254
+ name: "subType",
255
+ to: "subType",
256
+ ...SUB_TYPE,
257
+ required: true,
258
+ describe:
259
+ "Which kind of affiliation this is — the society's character.",
260
+ },
261
+ {
262
+ name: "society",
263
+ to: "society",
264
+ ...STRING,
265
+ default: "",
266
+ describe: "The body a member belongs to.",
267
+ },
268
+ {
269
+ name: "office",
270
+ to: "office",
271
+ ...STRING,
272
+ default: "",
273
+ describe: "The post a member holds within it.",
274
+ },
275
+ {
276
+ name: "title",
277
+ to: "title",
278
+ ...STRING,
279
+ default: "",
280
+ describe: "The style of address the office carries.",
281
+ },
282
+ {
283
+ name: "level",
284
+ to: "level",
285
+ ...NUMBER,
286
+ default: 0,
287
+ describe: "Standing within the society.",
288
+ },
289
+ {
290
+ name: "relation",
291
+ to: "relation",
292
+ ...RELATION,
293
+ default: {},
294
+ describe:
295
+ "How this society regards others: aligned, unaligned, rival or nemesis.",
296
+ },
297
+ ]),
298
+
299
+ affliction: Object.freeze([
300
+ {
301
+ name: "subType",
302
+ to: "subType",
303
+ ...SUB_TYPE,
304
+ required: true,
305
+ describe: "What kind of affliction it is.",
306
+ },
307
+ {
308
+ name: "category",
309
+ to: "category",
310
+ ...AS_AUTHORED,
311
+ default: "",
312
+ describe: "The grouping it belongs to within its subtype.",
313
+ },
314
+ {
315
+ to: "isDormant",
316
+ value: false,
317
+ describe: "Whether it is currently dormant. Play state.",
318
+ },
319
+ {
320
+ to: "isTreated",
321
+ value: false,
322
+ describe: "Whether it has been treated. Play state.",
323
+ },
324
+ {
325
+ name: "levelBase",
326
+ to: "levelBase",
327
+ ...NUMBER,
328
+ default: 0,
329
+ describe: "Severity, before any modifier.",
330
+ },
331
+ {
332
+ name: "healingRateBase",
333
+ to: "healingRateBase",
334
+ ...NUMBER,
335
+ default: 0,
336
+ describe: "How readily the host throws it off.",
337
+ },
338
+ {
339
+ name: "contagionIndex",
340
+ to: "contagionIndexBase",
341
+ ...NUMBER,
342
+ default: 0,
343
+ describe: "How readily it passes to someone else.",
344
+ },
345
+ {
346
+ name: "transmission",
347
+ to: "transmission",
348
+ ...AS_AUTHORED,
349
+ default: "none",
350
+ describe: "The route by which it spreads.",
351
+ },
352
+ {
353
+ name: "onsetFormula",
354
+ to: "onsetFormula",
355
+ ...BLANK_IS_NULL,
356
+ default: null,
357
+ describe:
358
+ "Days from contracting to onset, rolled by the receiving actor. Unset means no incubation.",
359
+ },
360
+ {
361
+ name: "outcome",
362
+ to: "outcome",
363
+ ...BLANK_IS_DEFAULT,
364
+ default: "cured",
365
+ describe:
366
+ "What running the course to the end does to the host: `death`, or the benign default `cured`.",
367
+ },
368
+ ]),
369
+
370
+ armorgear: Object.freeze([
371
+ ...GEAR_COMMON,
372
+ {
373
+ name: "material",
374
+ to: "material",
375
+ ...AS_AUTHORED,
376
+ default: "",
377
+ describe: "What the article is made of.",
378
+ },
379
+ {
380
+ name: "flexloc",
381
+ to: "locations.flexible",
382
+ ...BLANK_IS_DEFAULT,
383
+ default: [],
384
+ describe:
385
+ "Body locations the article covers flexibly, by location shortcode.",
386
+ },
387
+ {
388
+ name: "rigidloc",
389
+ to: "locations.rigid",
390
+ ...BLANK_IS_DEFAULT,
391
+ default: [],
392
+ describe:
393
+ "Body locations the article covers rigidly, by location shortcode.",
394
+ },
395
+ {
396
+ name: "facing",
397
+ to: "locations.facing",
398
+ ...FACING,
399
+ default: [],
400
+ describe:
401
+ "For a one-sided article, which side of each location it protects. Everything else protects from any direction and leaves this empty.",
402
+ },
403
+ {
404
+ name: "protection.blunt",
405
+ to: "protectionBase.blunt",
406
+ ...NUMBER,
407
+ default: 0,
408
+ describe: "Protection against blunt impact.",
409
+ },
410
+ {
411
+ name: "protection.edged",
412
+ to: "protectionBase.edged",
413
+ ...NUMBER,
414
+ default: 0,
415
+ describe: "Protection against edged impact.",
416
+ },
417
+ {
418
+ name: "protection.piercing",
419
+ to: "protectionBase.piercing",
420
+ ...NUMBER,
421
+ default: 0,
422
+ describe: "Protection against piercing impact.",
423
+ },
424
+ {
425
+ name: "protection.fire",
426
+ to: "protectionBase.fire",
427
+ ...NUMBER,
428
+ default: 0,
429
+ describe: "Protection against fire and heat.",
430
+ },
431
+ {
432
+ name: "encumbrance",
433
+ to: "encumbrance",
434
+ ...NUMBER,
435
+ default: 0,
436
+ describe: "What wearing it costs in encumbrance.",
437
+ },
438
+ {
439
+ name: "encumbranceGroup",
440
+ to: "encumbranceGroup",
441
+ ...BLANK_IS_NULL,
442
+ default: null,
443
+ describe:
444
+ "The set an article's encumbrance is charged to instead of carrying its own — the arm harness.",
445
+ },
446
+ {
447
+ name: "perceptionPenaltyBase",
448
+ to: "perceptionPenaltyBase",
449
+ ...NUMBER,
450
+ default: 0,
451
+ describe: "What wearing it costs in perception.",
452
+ },
453
+ ]),
454
+
455
+ attribute: Object.freeze([
456
+ {
457
+ name: "scoreBase",
458
+ to: "scoreBase",
459
+ ...NUMBER,
460
+ default: 0,
461
+ describe: "The attribute's score before any modifier.",
462
+ },
463
+ {
464
+ name: "valueDesc",
465
+ to: "valueDesc",
466
+ ...VALUE_DESC,
467
+ default: [],
468
+ describe:
469
+ "Descriptive bands for the score, each a label and the highest value it covers.",
470
+ },
471
+ {
472
+ name: "initDiceFormula",
473
+ to: "initDiceFormula",
474
+ ...AS_AUTHORED,
475
+ default: "",
476
+ describe:
477
+ "Dice expression rolled to generate the score during character creation.",
478
+ },
479
+ {
480
+ name: "impairedByRoles",
481
+ to: "impairedByRoles",
482
+ ...AS_AUTHORED,
483
+ default: [],
484
+ describe:
485
+ "Body-part roles whose impairment penalises tests against this attribute.",
486
+ },
487
+ ]),
488
+
489
+ concoctiongear: Object.freeze([
490
+ ...GEAR_COMMON,
491
+ {
492
+ name: "subType",
493
+ to: "subType",
494
+ ...SUB_TYPE,
495
+ required: true,
496
+ describe: "What kind of concoction it is.",
497
+ },
498
+ {
499
+ name: "potency",
500
+ to: "potency",
501
+ ...AS_AUTHORED,
502
+ default: "notApplicable",
503
+ describe: "How concentrated the preparation is.",
504
+ },
505
+ {
506
+ name: "strength",
507
+ to: "strength",
508
+ ...NUMBER,
509
+ default: 0,
510
+ describe: "How strongly it acts when it does.",
511
+ },
512
+ ]),
513
+
514
+ containergear: Object.freeze([
515
+ ...GEAR_COMMON,
516
+ {
517
+ name: "maxCapacity",
518
+ to: "maxCapacityBase",
519
+ ...NUMBER,
520
+ default: 0,
521
+ describe: "How much the container holds, in pounds.",
522
+ },
523
+ ]),
524
+
525
+ miscgear: Object.freeze([...GEAR_COMMON]),
526
+
527
+ mystery: Object.freeze([
528
+ {
529
+ name: "subType",
530
+ to: "subType",
531
+ ...SUB_TYPE,
532
+ required: true,
533
+ describe: "Which tradition the mystery belongs to.",
534
+ },
535
+ {
536
+ name: "assocSkillCode",
537
+ to: "assocSkillCode",
538
+ ref: "skill",
539
+ ...BLANK_IS_NULL,
540
+ default: null,
541
+ describe: "Shortcode of the skill the mystery is tested against.",
542
+ },
543
+ {
544
+ name: "assocAffiliationCode",
545
+ to: "assocAffiliationCode",
546
+ ref: "affiliation",
547
+ ...BLANK_IS_NULL,
548
+ default: null,
549
+ describe:
550
+ "Shortcode of the affiliation whose standing confers the mystery — a religion, school, or ancestor/totem/spirit.",
551
+ },
552
+ {
553
+ name: "levelBase",
554
+ to: "levelBase",
555
+ ...NUMBER,
556
+ default: 0,
557
+ describe: "The mystery's level before any modifier.",
558
+ },
559
+ {
560
+ name: "skillAptitudes",
561
+ to: "skillAptitudes",
562
+ ...SKILL_APTITUDES,
563
+ default: {},
564
+ describe: "Aptitude the mystery grants, per skill selector.",
565
+ },
566
+ {
567
+ name: "charges",
568
+ to: "charges",
569
+ ...CHARGES,
570
+ default: { value: null, max: null },
571
+ describe:
572
+ "Uses available and the pool's size. A blank maximum means the mystery does not use charges.",
573
+ },
574
+ ]),
575
+
576
+ mysticalability: Object.freeze([
577
+ {
578
+ name: "subType",
579
+ to: "subType",
580
+ ...SUB_TYPE,
581
+ required: true,
582
+ describe: "Which tradition the ability belongs to.",
583
+ },
584
+ {
585
+ name: "assocSkillCode",
586
+ to: "assocSkillCode",
587
+ ref: "skill",
588
+ ...AS_AUTHORED,
589
+ default: "",
590
+ describe: "Shortcode of the skill the ability is tested against.",
591
+ },
592
+ {
593
+ name: "assocAffiliationCode",
594
+ to: "assocAffiliationCode",
595
+ ref: "affiliation",
596
+ ...BLANK_IS_NULL,
597
+ default: null,
598
+ describe:
599
+ "Shortcode of the affiliation whose standing confers the ability — a religion, school, or ancestor/totem/spirit.",
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
+ {
610
+ name: "masteryLevelBase",
611
+ to: "masteryLevelBase",
612
+ ...NUMBER,
613
+ default: 0,
614
+ describe: "Mastery in the ability before any modifier.",
615
+ },
616
+ {
617
+ name: "improveFlag",
618
+ to: "improveFlag",
619
+ ...BOOLEAN,
620
+ default: false,
621
+ describe: "Whether it is flagged for improvement.",
622
+ },
623
+ {
624
+ name: "levelBase",
625
+ to: "levelBase",
626
+ ...NUMBER,
627
+ default: 0,
628
+ describe: "The ability's level before any modifier.",
629
+ },
630
+ {
631
+ name: "charges",
632
+ to: "charges",
633
+ ...CHARGES,
634
+ default: { value: null, max: null },
635
+ describe:
636
+ "Uses available and the pool's size. A blank maximum means the ability does not use charges.",
637
+ },
638
+ ]),
639
+
640
+ projectilegear: Object.freeze([
641
+ ...GEAR_COMMON,
642
+ {
643
+ name: "subType",
644
+ to: "subType",
645
+ ...SUB_TYPE,
646
+ required: true,
647
+ describe: "What kind of projectile it is.",
648
+ },
649
+ {
650
+ name: "impact.overrideDice",
651
+ to: "impactBase.overrideDice",
652
+ shape: "boolean",
653
+ read: (raw, { fm }) => Boolean(raw ?? impactDie(fm) > 0),
654
+ describe:
655
+ "Whether the projectile's dice replace the launching weapon's. Defaults to true when the projectile declares a die.",
656
+ },
657
+ {
658
+ name: "impact.overrideModifier",
659
+ to: "impactBase.overrideModifier",
660
+ shape: "boolean",
661
+ read: (raw) => Boolean(raw ?? false),
662
+ describe:
663
+ "Whether the projectile's modifier replaces the launching weapon's.",
664
+ },
665
+ {
666
+ to: "impactBase.numDice",
667
+ value: (fm) => (impactDie(fm) > 0 ? 1 : 0),
668
+ describe:
669
+ "How many dice are rolled. Derived: one when a die is declared, none otherwise.",
670
+ },
671
+ {
672
+ name: "impact.die",
673
+ to: "impactBase.die",
674
+ ...NUMBER,
675
+ default: 0,
676
+ describe: "The impact die's size.",
677
+ },
678
+ {
679
+ name: "impact.modifier",
680
+ to: "impactBase.modifier",
681
+ ...NUMBER,
682
+ default: 0,
683
+ describe: "Flat addition to the impact roll.",
684
+ },
685
+ {
686
+ name: "impact.aspect",
687
+ to: "impactBase.aspect",
688
+ ...BLANK_IS_DEFAULT,
689
+ default: "piercing",
690
+ describe: "How the projectile wounds.",
691
+ },
692
+ ]),
693
+
694
+ skill: Object.freeze([
695
+ {
696
+ name: "subType",
697
+ to: "subType",
698
+ ...SUB_TYPE,
699
+ required: true,
700
+ describe: "Which family of skill it is.",
701
+ },
702
+ {
703
+ name: "skillBaseFormula",
704
+ to: "skillBaseFormula",
705
+ ...AS_AUTHORED,
706
+ default: "",
707
+ describe:
708
+ "Expression deriving the skill's base from the actor's attributes, e.g. `sb(attr.str, attr.agl)`.",
709
+ },
710
+ {
711
+ name: "masteryLevelBase",
712
+ to: "masteryLevelBase",
713
+ ...NULLABLE_NUMBER,
714
+ default: null,
715
+ describe:
716
+ "Opened mastery level. Unset means _not yet opened_ — an embedded copy opens on its actor at Skill Base × `initSkillMult`.",
717
+ },
718
+ {
719
+ name: "improveFlag",
720
+ to: "improveFlag",
721
+ ...BOOLEAN,
722
+ default: false,
723
+ describe: "Whether it is flagged for improvement.",
724
+ },
725
+ {
726
+ name: "combatCategory",
727
+ to: "combatCategory",
728
+ ...AS_AUTHORED,
729
+ default: "none",
730
+ describe: "Which combat role the skill fills, if any.",
731
+ },
732
+ {
733
+ name: "parentSkillCode",
734
+ to: "parentSkillCode",
735
+ ref: "skill",
736
+ ...AS_AUTHORED,
737
+ default: "",
738
+ describe:
739
+ "Shortcode of the skill this one specialises, for a specialisation.",
740
+ },
741
+ {
742
+ name: "initSkillMult",
743
+ to: "initSkillMult",
744
+ ...NUMBER,
745
+ default: 0,
746
+ describe:
747
+ "Multiplier applied to Skill Base when the skill opens on an actor.",
748
+ },
749
+ {
750
+ name: "impairedByRoles",
751
+ to: "impairedByRoles",
752
+ ...AS_AUTHORED,
753
+ default: [],
754
+ describe:
755
+ "Body-part roles whose impairment penalises tests against this skill.",
756
+ },
757
+ ]),
758
+
759
+ trauma: Object.freeze([
760
+ {
761
+ name: "subType",
762
+ to: "subType",
763
+ ...SUB_TYPE,
764
+ required: true,
765
+ describe: "What kind of trauma it is.",
766
+ },
767
+ {
768
+ name: "category",
769
+ to: "category",
770
+ ...AS_AUTHORED,
771
+ default: null,
772
+ describe: "The grouping it belongs to within its subtype.",
773
+ },
774
+ {
775
+ name: "levelBase",
776
+ to: "levelBase",
777
+ ...NULLABLE_COUNT,
778
+ default: null,
779
+ describe:
780
+ "Injury level. Unset on a descriptive condition, which has no level.",
781
+ },
782
+ {
783
+ name: "healingRateBase",
784
+ to: "healingRateBase",
785
+ ...NUMBER,
786
+ default: 0,
787
+ describe: "How readily it heals.",
788
+ },
789
+ {
790
+ name: "aspect",
791
+ to: "aspect",
792
+ ...AS_AUTHORED,
793
+ default: null,
794
+ describe:
795
+ "How the injury was inflicted. Unset on a descriptive condition.",
796
+ },
797
+ {
798
+ name: "isTreated",
799
+ to: "isTreated",
800
+ ...BOOLEAN,
801
+ default: false,
802
+ describe: "Whether it has been treated.",
803
+ },
804
+ {
805
+ name: "isBleeding",
806
+ to: "isBleeding",
807
+ ...BOOLEAN,
808
+ default: false,
809
+ describe: "Whether it is bleeding.",
810
+ },
811
+ {
812
+ name: "bodyLocationCode",
813
+ to: "bodyLocationCode",
814
+ ...AS_AUTHORED,
815
+ default: null,
816
+ describe:
817
+ "Shortcode of the body location injured. Unset on a descriptive condition.",
818
+ },
819
+ ]),
820
+
821
+ weapongear: Object.freeze([
822
+ ...GEAR_COMMON,
823
+ {
824
+ name: "encumbrance",
825
+ to: "encumbranceBase",
826
+ ...NUMBER,
827
+ default: 0,
828
+ describe: "What carrying it costs in encumbrance.",
829
+ },
830
+ {
831
+ name: "heft",
832
+ to: "heftBase",
833
+ ...NUMBER,
834
+ default: 0,
835
+ describe: "How unwieldy it is in the hand.",
836
+ },
837
+ {
838
+ name: "strikeModes",
839
+ to: "strikeModes",
840
+ ...STRIKE_MODES,
841
+ default: [],
842
+ describe: "The ways the weapon can be used to strike.",
843
+ },
844
+ ]),
845
+ });
846
+
847
+ /**
848
+ * The one conditional field in the vocabulary: a combat technique's strike
849
+ * mode.
850
+ *
851
+ * A combat technique is authored as a `skill` of subtype `combattechnique` —
852
+ * the standalone item type was merged into Skill — and carries an embedded,
853
+ * discriminated strike mode. It is mandatory for that subtype and absent from
854
+ * every other skill, which is a conditional a flat field list cannot state, so
855
+ * it is applied after the declaration runs.
856
+ *
857
+ * @type {import("../engine/field-spec.mjs").FieldSpec}
858
+ */
859
+ export const COMBAT_TECHNIQUE_STRIKE_MODE = Object.freeze({
860
+ name: "strikeMode",
861
+ to: "strikeMode",
862
+ ...STRIKE_MODE,
863
+ required: true,
864
+ default: null,
865
+ describe:
866
+ "The strike mode the technique trains. Required on a `combattechnique` skill, and set on no other.",
867
+ });