@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,420 @@
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
+ * **Scene ↔ Level integrity** for a compiled compendium pack (issue #1538).
16
+ *
17
+ * A v14 Scene keeps its map image on an embedded `Level`, and a compiled pack
18
+ * stores the two in *separate* LevelDB keys: the Scene at `!scenes!<id>`
19
+ * holding `levels` as an array of ids, and each Level at
20
+ * `!scenes.levels!<sceneId>.<levelId>`. Nothing in Foundry ties them together
21
+ * on read. If a Level record is missing, `EmbeddedCollectionField#expandEmbedded`
22
+ * merely warns
23
+ *
24
+ * > _N embedded levels records in Level `<sceneId>` were undefined and not
25
+ * > retrieved from the scenes.levels sublevel_
26
+ *
27
+ * and yields an empty collection. The very next world launch migrates that
28
+ * scene, **persists `levels: []`**, and leaves `initialLevel` pointing at an id
29
+ * that no longer exists — measured on both 14.359 and 14.367. The map image is
30
+ * gone for good, and the only symptom a human sees is a blank battlemap.
31
+ *
32
+ * Foundry is behaving correctly there: a scene with no Level records genuinely
33
+ * has no levels. The damage is that the condition is *unobservable* until it is
34
+ * permanent. So the pack build asserts the invariant on the artefact it just
35
+ * wrote — the compiled LevelDB, not the JSON it was compiled from — because the
36
+ * gap this closes is the write path (the emitter is already unit-tested, and
37
+ * the compendium CLI has previously mishandled Scene Levels).
38
+ *
39
+ * An {@link https://foundryvtt.com/api/classes/foundry.documents.BaseAdventure.html Adventure}
40
+ * carries its scenes *inline*, levels and all, so the same invariant has a
41
+ * second shape and a second way to ship a mapless map; both are checked here.
42
+ *
43
+ * Plain ESM with no Foundry, so the rule itself is a pure function over records
44
+ * and is unit-tested directly.
45
+ *
46
+ * **What a report has to say (#9).** The likeliest way to meet any of this is a
47
+ * compendium CLI older than {@link LEVEL_SPLITTING_CLI}, which does not split
48
+ * Scene Levels into the sublevel at all — so the guard fires on every scene at
49
+ * once, and each `levels` entry is an inline Level *object* rather than an id.
50
+ * Two rules keep that legible: an entry is named by the **shape it has** (an
51
+ * object interpolated into a key reads `[object Object]` and names a key that
52
+ * could not exist), and a pack with **no** `!scenes.levels!` records at all is
53
+ * one fact about the compile, reported once, not once per scene.
54
+ *
55
+ * @module
56
+ */
57
+
58
+ import { createRequire } from "node:module";
59
+
60
+ import { ClassicLevel } from "classic-level";
61
+
62
+ /** LevelDB key prefix for a pack's primary Scene records. */
63
+ const SCENE_PREFIX = "!scenes!";
64
+
65
+ /** LevelDB key prefix for the `levels` sublevel of those Scene records. */
66
+ const LEVEL_PREFIX = "!scenes.levels!";
67
+
68
+ /** LevelDB key prefix for a pack's Adventure records. */
69
+ const ADVENTURE_PREFIX = "!adventures!";
70
+
71
+ /**
72
+ * The compendium CLI release that began splitting Scene Levels into the
73
+ * `!scenes.levels!` sublevel. Anything older writes them inline on the Scene
74
+ * document, which is the shape the reports below name (#9).
75
+ */
76
+ const LEVEL_SPLITTING_CLI = "3.0.3";
77
+
78
+ /**
79
+ * The version of `@foundryvtt/foundryvtt-cli` this build actually resolves.
80
+ *
81
+ * Resolved from *this* module, so it names the copy the pack compile runs on
82
+ * rather than whatever a shell happens to find. The installed version is the
83
+ * write path, so it is the fact a report should carry.
84
+ *
85
+ * @returns {string | undefined} The resolved version, or `undefined` when the
86
+ * package cannot be read (a pruned install, an exports-restricted copy).
87
+ */
88
+ export function compendiumCliVersion() {
89
+ try {
90
+ return createRequire(import.meta.url)(
91
+ "@foundryvtt/foundryvtt-cli/package.json",
92
+ ).version;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Is a `levels` entry an inline Level document rather than an id?
100
+ *
101
+ * @param {unknown} entry - One entry of a scene's `levels` array.
102
+ * @returns {boolean} True for an object, which on a compiled `!scenes!` record
103
+ * is always the wrong shape.
104
+ */
105
+ function isInlineLevel(entry) {
106
+ return typeof entry === "object" && entry !== null;
107
+ }
108
+
109
+ /**
110
+ * The Level id an entry names, in whichever shape it named it.
111
+ *
112
+ * @param {unknown} entry - One entry of a scene's `levels` array.
113
+ * @returns {string | undefined} The id, or `undefined` when it names none.
114
+ */
115
+ function levelIdOf(entry) {
116
+ if (typeof entry === "string" && entry) return entry;
117
+ if (isInlineLevel(entry) && typeof entry._id === "string" && entry._id) {
118
+ return entry._id;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ /**
124
+ * Name a `levels` entry by the shape it actually has.
125
+ *
126
+ * @param {unknown} entry - One entry of a scene's `levels` array.
127
+ * @param {number} index - Its position, which the report cites.
128
+ * @returns {string} A phrase naming the entry, never `[object Object]`.
129
+ */
130
+ function describeEntry(entry, index) {
131
+ if (isInlineLevel(entry))
132
+ return `\`levels[${index}]\` is ${describeShape(entry)}`;
133
+ return `\`levels[${index}]\` is ${JSON.stringify(entry) ?? String(entry)}`;
134
+ }
135
+
136
+ /**
137
+ * Name an inline Level object by its `_id`, the fact that identifies it.
138
+ *
139
+ * @param {object} entry - An inline Level object.
140
+ * @returns {string} A phrase naming it, never `[object Object]`.
141
+ */
142
+ function describeShape(entry) {
143
+ const id = levelIdOf(entry);
144
+ return `an inline Level object (\`_id\`: ${id ? `"${id}"` : "absent"})`;
145
+ }
146
+
147
+ /**
148
+ * The `levels` a scene declares, in whatever shape it declared them.
149
+ *
150
+ * @param {object} scene - The scene document.
151
+ * @returns {unknown[]} Its declared entries, empty when it declares none.
152
+ */
153
+ function declaredLevels(scene) {
154
+ return Array.isArray(scene?.levels) ? scene.levels : [];
155
+ }
156
+
157
+ /**
158
+ * How to name a scene in a problem report.
159
+ *
160
+ * @param {string} sceneId - The scene's id.
161
+ * @param {object} scene - The scene document.
162
+ * @returns {string} A phrase naming it by name and id.
163
+ */
164
+ function nameScene(sceneId, scene) {
165
+ return `Scene "${scene?.name ?? sceneId}" [${sceneId}]`;
166
+ }
167
+
168
+ /**
169
+ * Does a compendium CLI version predate {@link LEVEL_SPLITTING_CLI}?
170
+ *
171
+ * @param {string | undefined} version - A resolved version, if one is known.
172
+ * @returns {boolean | undefined} Whether it predates the split, or `undefined`
173
+ * when there is no version to judge or it does not parse.
174
+ */
175
+ function predatesLevelSplitting(version) {
176
+ const parse = (value) => String(value).split(/[.+-]/, 3).map(Number);
177
+ const found = parse(version);
178
+ if (found.length < 3 || found.some((part) => !Number.isInteger(part))) {
179
+ return undefined;
180
+ }
181
+ const want = parse(LEVEL_SPLITTING_CLI);
182
+ for (const [index, part] of found.entries()) {
183
+ if (part !== want[index]) return part < want[index];
184
+ }
185
+ return false;
186
+ }
187
+
188
+ /**
189
+ * What to do about it, decided from the version actually resolved — so the
190
+ * report ends in an instruction rather than a lead to follow (#9).
191
+ *
192
+ * @param {string | undefined} cliVersion - The resolved compendium CLI
193
+ * version, when it is known.
194
+ * @returns {string} A sentence naming the next step.
195
+ */
196
+ function describeRemedy(cliVersion) {
197
+ const older = predatesLevelSplitting(cliVersion);
198
+ if (older === true) {
199
+ return (
200
+ `The resolved \`@foundryvtt/foundryvtt-cli\` is ${cliVersion}, ` +
201
+ `which predates ${LEVEL_SPLITTING_CLI}: install ` +
202
+ `${LEVEL_SPLITTING_CLI} or newer and recompile.`
203
+ );
204
+ }
205
+ if (older === false) {
206
+ return (
207
+ `The resolved \`@foundryvtt/foundryvtt-cli\` is ${cliVersion}, ` +
208
+ `which does split them — so check ` +
209
+ `\`npm ls @foundryvtt/foundryvtt-cli\` for a second, older copy, ` +
210
+ `then recompile.`
211
+ );
212
+ }
213
+ return (
214
+ `Check the installed \`@foundryvtt/foundryvtt-cli\` ` +
215
+ `(\`npm ls @foundryvtt/foundryvtt-cli\`; ${LEVEL_SPLITTING_CLI} or ` +
216
+ `newer is required) and recompile.`
217
+ );
218
+ }
219
+
220
+ /**
221
+ * The pack declares Levels and holds **no** `!scenes.levels!` records at all.
222
+ *
223
+ * That is one fact about the compile, not one per scene, so it is reported
224
+ * once with every affected scene named. A pack that lost a single record lost
225
+ * it on that record's write path; a pack that has none never split them, which
226
+ * is what a compendium CLI older than {@link LEVEL_SPLITTING_CLI} does — two
227
+ * different diagnoses, so the message names whichever shape it found rather
228
+ * than guessing (#9).
229
+ *
230
+ * @param {Array<[string, object]>} declaring - `[sceneId, scene]` for every
231
+ * scene declaring at least one level.
232
+ * @param {string | undefined} cliVersion - The resolved compendium CLI
233
+ * version, when it is known.
234
+ * @returns {string} One report covering every affected scene.
235
+ */
236
+ function describeWholesaleLoss(declaring, cliVersion) {
237
+ const inline = declaring.some(([, scene]) =>
238
+ declaredLevels(scene).some(isInlineLevel),
239
+ );
240
+ const list = declaring
241
+ .map(([sceneId, scene]) => {
242
+ const entries = declaredLevels(scene)
243
+ .map((entry) =>
244
+ isInlineLevel(entry) ? describeShape(entry) : `"${entry}"`,
245
+ )
246
+ .join(", ");
247
+ return `${nameScene(sceneId, scene)} → ${entries}`;
248
+ })
249
+ .join("; ");
250
+ const cause =
251
+ inline ?
252
+ `Those entries are inline Level objects rather than ids, which is ` +
253
+ `what \`@foundryvtt/foundryvtt-cli\` older than ` +
254
+ `${LEVEL_SPLITTING_CLI} writes: it does not split Scene Levels ` +
255
+ `into the ${LEVEL_PREFIX} sublevel.`
256
+ : `The whole ${LEVEL_PREFIX} sublevel is missing rather than one ` +
257
+ `record, so the fault is in the compile step and not in any one ` +
258
+ `Scene.`;
259
+ const count = declaring.length;
260
+ return (
261
+ `The compiled pack has no ${LEVEL_PREFIX} records at all, but ` +
262
+ `${count} Scene${count === 1 ? "" : "s"} ` +
263
+ `declare${count === 1 ? "s" : ""} Levels — ${list} — so every one of ` +
264
+ `those map images is lost. ${cause} ${describeRemedy(cliVersion)}`
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Check the `levels` a scene declares, whatever shape it declared them in.
270
+ *
271
+ * Each violation is reported once, at its most specific: a level id whose
272
+ * record is missing is reported by the caller, and does not also count as the
273
+ * scene "having no Level" — one broken fact, one message.
274
+ *
275
+ * @param {object} scene - The scene document.
276
+ * @param {string[]} levelIds - The Level ids the scene declares.
277
+ * @param {string} where - How to name the scene in a problem report.
278
+ * @returns {string[]} A problem per broken rule; empty when the scene is sound.
279
+ */
280
+ function checkDeclaredLevels(scene, levelIds, where) {
281
+ if (!levelIds.length) {
282
+ return [
283
+ `${where} has no Level — its map image cannot be stored, and ` +
284
+ `Foundry will persist \`levels: []\` on the next world launch.`,
285
+ ];
286
+ }
287
+ const initial = scene.initialLevel;
288
+ if (initial && !levelIds.includes(initial)) {
289
+ return [
290
+ `${where} names initialLevel "${initial}", which is not one of ` +
291
+ `its levels (${levelIds.join(", ")}) — a dangling reference.`,
292
+ ];
293
+ }
294
+ return [];
295
+ }
296
+
297
+ /**
298
+ * Every way a compiled pack can ship a Scene that has lost its Level.
299
+ *
300
+ * @param {Iterable<[string, object]>} records - `[key, value]` pairs from a
301
+ * compiled pack's LevelDB, in any order.
302
+ * @param {object} [options] - Reporting context.
303
+ * @param {string} [options.cliVersion] - The resolved compendium CLI version,
304
+ * which decides what a wholesale loss is blamed on. Defaults to unknown,
305
+ * which leaves the report to say only what it can prove.
306
+ * @returns {string[]} One human-readable problem per violation, empty when the
307
+ * pack is sound.
308
+ */
309
+ export function checkSceneLevels(records, { cliVersion } = {}) {
310
+ /** @type {Array<[string, object]>} `!scenes!` records, by key. */
311
+ const scenes = [];
312
+ /** @type {Set<string>} `<sceneId>.<levelId>` for every sublevel record. */
313
+ const levelKeys = new Set();
314
+ /** @type {Array<object>} `!adventures!` records. */
315
+ const adventures = [];
316
+
317
+ for (const [key, value] of records) {
318
+ if (key.startsWith(LEVEL_PREFIX)) {
319
+ levelKeys.add(key.slice(LEVEL_PREFIX.length));
320
+ } else if (key.startsWith(SCENE_PREFIX)) {
321
+ scenes.push([key.slice(SCENE_PREFIX.length), value]);
322
+ } else if (key.startsWith(ADVENTURE_PREFIX)) {
323
+ adventures.push(value);
324
+ }
325
+ }
326
+
327
+ const problems = [];
328
+
329
+ // No scene in the pack has a Level record: one fact about the compile,
330
+ // which saying per scene would bury. Scenes declaring nothing are not
331
+ // covered by it, so they are still judged individually below.
332
+ const declaring = scenes.filter(
333
+ ([, scene]) => declaredLevels(scene).length,
334
+ );
335
+ const wholesale = levelKeys.size === 0 && declaring.length > 0;
336
+ if (wholesale) problems.push(describeWholesaleLoss(declaring, cliVersion));
337
+
338
+ for (const [sceneId, scene] of scenes) {
339
+ const declared = declaredLevels(scene);
340
+ if (wholesale && declared.length) continue;
341
+ const where = nameScene(sceneId, scene);
342
+
343
+ // Judge each entry by the shape it has: a compiled `!scenes!` record
344
+ // stores ids, so an inline Level object is itself the defect, and
345
+ // naming it is what identifies the cause.
346
+ const broken = [];
347
+ declared.forEach((entry, index) => {
348
+ const id = levelIdOf(entry);
349
+ if (isInlineLevel(entry)) {
350
+ broken.push(
351
+ `${where}: ${describeEntry(entry, index)}, not an id — a ` +
352
+ `compiled pack stores each Level as its own ` +
353
+ `${LEVEL_PREFIX}<sceneId>.<levelId> record, and none ` +
354
+ `exists at ${LEVEL_PREFIX}${sceneId}.${id ?? "<id>"}. ` +
355
+ `Inline Levels are what ` +
356
+ `\`@foundryvtt/foundryvtt-cli\` older than ` +
357
+ `${LEVEL_SPLITTING_CLI} writes.`,
358
+ );
359
+ } else if (!id) {
360
+ broken.push(
361
+ `${where}: ${describeEntry(entry, index)}, not a Level id.`,
362
+ );
363
+ } else if (!levelKeys.has(`${sceneId}.${id}`)) {
364
+ broken.push(
365
+ `${where} lists level "${id}", but no record exists at ` +
366
+ `${LEVEL_PREFIX}${sceneId}.${id} — the map image is ` +
367
+ `lost.`,
368
+ );
369
+ }
370
+ });
371
+
372
+ // A broken entry is already reported above; only the declaration
373
+ // itself is judged here, so nothing is reported twice.
374
+ if (broken.length) problems.push(...broken);
375
+ else
376
+ problems.push(...checkDeclaredLevels(scene ?? {}, declared, where));
377
+ }
378
+
379
+ for (const adventure of adventures) {
380
+ const inline = Array.isArray(adventure?.scenes) ? adventure.scenes : [];
381
+ for (const scene of inline) {
382
+ const levelIds = declaredLevels(scene).map(
383
+ (level) => levelIdOf(level) ?? level,
384
+ );
385
+ const where =
386
+ `Adventure "${adventure?.name ?? adventure?._id}" scene ` +
387
+ `"${scene?.name ?? scene?._id}"`;
388
+ problems.push(...checkDeclaredLevels(scene ?? {}, levelIds, where));
389
+ }
390
+ }
391
+
392
+ return problems;
393
+ }
394
+
395
+ /**
396
+ * Read a compiled pack back off disk and check it.
397
+ *
398
+ * The pack is opened after the compendium CLI has closed it, so this reads the
399
+ * bytes that will actually ship rather than the JSON they were compiled from.
400
+ *
401
+ * @param {string} packDir - Directory of the compiled LevelDB pack.
402
+ * @returns {Promise<string[]>} The problems found, empty when the pack is sound.
403
+ */
404
+ export async function verifyPackSceneLevels(packDir) {
405
+ const db = new ClassicLevel(packDir, {
406
+ keyEncoding: "utf8",
407
+ valueEncoding: "json",
408
+ createIfMissing: false,
409
+ });
410
+ await db.open();
411
+ try {
412
+ const records = [];
413
+ for await (const entry of db.iterator()) records.push(entry);
414
+ return checkSceneLevels(records, {
415
+ cliVersion: compendiumCliVersion(),
416
+ });
417
+ } finally {
418
+ await db.close();
419
+ }
420
+ }