@heroiclands/package-build 0.6.0 → 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 +95 -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 -34
  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,528 @@
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
+ * Scenes pack compiler — map notes in `assets/content/` → Foundry `Scene`
16
+ * documents, and the `Adventure` bundles that make their references resolve
17
+ * (issue #1525).
18
+ *
19
+ * The translation itself lives in the framework-free `map-notes.mjs`; this
20
+ * module is the pass that walks the tree, resolves what one note says about
21
+ * another, and writes the JSON the compendium CLI compiles.
22
+ *
23
+ * **Two outputs, for two different jobs.**
24
+ *
25
+ * - The **`scenes` pack** holds every map note's Scene. It is what a wikilink
26
+ * to a map addresses, and what a GM browses.
27
+ * - The **`adventures` pack** holds one Adventure per *place* — a group of map
28
+ * notes sharing a `place:`, defaulting to the note's own shortcode — bundling
29
+ * those scenes with the JournalEntries their prose compiled into. A map with
30
+ * `locations:` **must** be imported this way: `Adventure#importContent`
31
+ * creates with `keepId: true`, and a pin's `Note.entryId` / `pageId`, a
32
+ * `teleportToken` destination and a `toggleBehavior` target are all
33
+ * id-based. Dragged out of the bare `scenes` pack, a pinned scene lands with
34
+ * pins pointing at ids no document in the world carries.
35
+ *
36
+ * **Cross-references are addresses, never UUIDs.** A note says
37
+ * `to: {map: manorsolar, region: stair-landing}`; this pass turns that into
38
+ * `Scene.<id>.Region.<id>` from an index built before any scene is compiled,
39
+ * because every embedded id is derived from the scene id and the authored key
40
+ * rather than stored. It is the same vocabulary the link manifest uses.
41
+ *
42
+ * Not a standalone script — exports the `Scenes` compiler class, imported and
43
+ * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
44
+ *
45
+ * The walk itself — filtering by package and type, skipping drafts,
46
+ * expanding tables, converting wikilinks, writing the JSON and counting
47
+ * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
48
+ * states only what makes this pass its own (#1509).
49
+ */
50
+
51
+ import fs from "fs";
52
+ import path from "path";
53
+ import log from "loglevel";
54
+
55
+ import {
56
+ walkMarkdownTree,
57
+ sohlField,
58
+ resolveName,
59
+ slugify,
60
+ defaultStats,
61
+ } from "./helpers.mjs";
62
+ import { BasePackCompiler } from "./base-compiler.mjs";
63
+ import { buildJournalEntry, splitPages, journalPageId } from "./journals.mjs";
64
+ import { compendiumUuid, makeId, packForType } from "./ids.mjs";
65
+ import { packRouter } from "./pack-router.mjs";
66
+ import { contentPackage, foundryPackageId } from "./content-package.mjs";
67
+ import { itemDocEntryId } from "./item-docs.mjs";
68
+ import {
69
+ behaviorDocId,
70
+ buildScene,
71
+ isMapType,
72
+ regionDocId,
73
+ } from "./map-notes.mjs";
74
+
75
+ /**
76
+ * Every SoHL action name this build knows about, for the `action:` warning on a
77
+ * region trigger.
78
+ *
79
+ * Deliberately a **superset**, gathered from the localization keys every
80
+ * intrinsic action's `title:` points at and from the `shortcode:` / `executor:`
81
+ * string literals the action definitions carry. A warning that fires on a real
82
+ * action would be worse than one that misses a typo, so the wider net is the
83
+ * right one: this only has to recognise the names that exist, not enumerate
84
+ * them exactly.
85
+ *
86
+ * @param {string} repoRoot - The repository root.
87
+ * @returns {Set<string>} The known action names.
88
+ */
89
+ export function collectKnownActionNames(repoRoot) {
90
+ const names = new Set();
91
+ const langFile = path.join(repoRoot, "lang", "en.json");
92
+ if (fs.existsSync(langFile)) {
93
+ const lang = JSON.parse(fs.readFileSync(langFile, "utf8"));
94
+ for (const key of Object.keys(lang)) {
95
+ const at = key.indexOf(".Action.");
96
+ if (at < 0) continue;
97
+ const [leaf] = key.slice(at + ".Action.".length).split(".");
98
+ if (/^[A-Za-z][A-Za-z0-9]*$/.test(leaf)) names.add(leaf);
99
+ }
100
+ }
101
+ const srcRoot = path.join(repoRoot, "src");
102
+ const pattern = /(?:shortcode|executor):\s*"([A-Za-z][A-Za-z0-9]*)"/g;
103
+ const walk = (dir) => {
104
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
105
+ const abs = path.join(dir, entry.name);
106
+ if (entry.isDirectory()) walk(abs);
107
+ else if (entry.name.endsWith(".ts")) {
108
+ const text = fs.readFileSync(abs, "utf8");
109
+ for (const m of text.matchAll(pattern)) names.add(m[1]);
110
+ }
111
+ }
112
+ };
113
+ if (fs.existsSync(srcRoot)) walk(srcRoot);
114
+ return names;
115
+ }
116
+
117
+ /**
118
+ * Strip the LevelDB keys from a document tree.
119
+ *
120
+ * An Adventure's members are inline source data in a `SetField`, not sublevel
121
+ * documents, so they carry no `_key` — the CLI's hierarchy does not recurse
122
+ * into an adventure, and Foundry's schema has no such field to hold it.
123
+ *
124
+ * @param {*} value - A document, array, or scalar.
125
+ * @returns {*} The same shape with every `_key` removed.
126
+ */
127
+ function stripKeys(value) {
128
+ if (Array.isArray(value)) return value.map(stripKeys);
129
+ if (value && typeof value === "object") {
130
+ return Object.fromEntries(
131
+ Object.entries(value)
132
+ .filter(([k]) => k !== "_key")
133
+ .map(([k, v]) => [k, stripKeys(v)]),
134
+ );
135
+ }
136
+ return value;
137
+ }
138
+
139
+ export class Scenes extends BasePackCompiler {
140
+ static id = "scenes";
141
+ static label = "map";
142
+
143
+ /** @type {string} */
144
+ adventureDir;
145
+
146
+ /**
147
+ * Adventures this pass bundled, for the summary.
148
+ *
149
+ * @type {number}
150
+ */
151
+ adventureCount = 0;
152
+
153
+ constructor({
154
+ contentBase,
155
+ dest,
156
+ companionDests = {},
157
+ folderResolver = () => null,
158
+ repoRoot = process.cwd(),
159
+ }) {
160
+ super({ contentBase, dest, folderResolver });
161
+ if (!companionDests.adventures) {
162
+ throw new Error(
163
+ "Scenes compiler requires an `adventures` companion destination",
164
+ );
165
+ }
166
+ Object.defineProperty(this, "adventureDir", {
167
+ value: companionDests.adventures,
168
+ writable: false,
169
+ });
170
+ Object.defineProperty(this, "repoRoot", {
171
+ value: repoRoot,
172
+ writable: false,
173
+ });
174
+ }
175
+
176
+ /**
177
+ * @param {object} fm - The note's frontmatter.
178
+ * @returns {boolean} True for a map note.
179
+ */
180
+ selects(fm) {
181
+ return isMapType(fm.type);
182
+ }
183
+
184
+ /**
185
+ * Collect every map note in the tree, and every item note's Active Effects.
186
+ *
187
+ * @returns {{maps: Array<object>, effectsByAddress: Map<string, object>}}
188
+ */
189
+ #collect() {
190
+ const maps = [];
191
+ const effectsByAddress = new Map();
192
+ for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
193
+ this.contentBase,
194
+ )) {
195
+ if (!fm || fm.package !== contentPackage() || !fm.id) continue;
196
+ if (
197
+ fm.shortcode &&
198
+ Array.isArray(fm.effects) &&
199
+ fm.effects.length
200
+ ) {
201
+ effectsByAddress.set(`${fm.type}-${fm.shortcode}`, {
202
+ id: fm.id,
203
+ type: fm.type,
204
+ // Where the owning item landed, so a region behaviour's
205
+ // effect reference addresses the right pack when a
206
+ // repository ships several of one type (#1566).
207
+ pack: packRouter().resolveOrNull(
208
+ fm,
209
+ packForType(fm.type).docType,
210
+ ),
211
+ effects: fm.effects,
212
+ });
213
+ }
214
+ if (!isMapType(fm.type)) continue;
215
+ if (fm.draft === true) {
216
+ log.debug(`Skipping draft map: ${absPath}`);
217
+ continue;
218
+ }
219
+ maps.push({ fm, body, absPath });
220
+ }
221
+ return { maps, effectsByAddress };
222
+ }
223
+
224
+ /**
225
+ * Index every map's derived region and behaviour ids, keyed by shortcode.
226
+ *
227
+ * Built before any scene is compiled, because a cross-reference in the
228
+ * first note may address the last. Every id is a pure function of the
229
+ * scene id and the authored key (or an authored `_id`), so this needs
230
+ * nothing but frontmatter.
231
+ *
232
+ * @param {Array<object>} maps - The collected map notes.
233
+ * @returns {Map<string, object>} shortcode → `{sceneId, name, regions}`.
234
+ */
235
+ #indexMaps(maps) {
236
+ const index = new Map();
237
+ for (const { fm, absPath } of maps) {
238
+ if (!fm.shortcode) {
239
+ throw new Error(`Map note missing shortcode: ${absPath}`);
240
+ }
241
+ if (index.has(fm.shortcode)) {
242
+ throw new Error(
243
+ `Two map notes share the shortcode "${fm.shortcode}"`,
244
+ );
245
+ }
246
+ const regions = new Map();
247
+ for (const [key, spec] of Object.entries(fm.sohl?.regions ?? {})) {
248
+ const id = regionDocId(fm.id, key, spec?._id);
249
+ const behaviors = new Map();
250
+ for (const [bKey, bSpec] of Object.entries(
251
+ spec?.behaviors ?? {},
252
+ )) {
253
+ behaviors.set(bKey, behaviorDocId(id, bKey, bSpec?._id));
254
+ }
255
+ regions.set(key, { id, behaviors });
256
+ }
257
+ index.set(fm.shortcode, {
258
+ sceneId: fm.id,
259
+ name: resolveName(fm),
260
+ regions,
261
+ });
262
+ }
263
+ return index;
264
+ }
265
+
266
+ /**
267
+ * Build the three address → UUID resolvers a scene's behaviours need.
268
+ *
269
+ * @param {Map<string, object>} index - From {@link Scenes#indexMaps}.
270
+ * @param {Map<string, object>} effectsByAddress - Item notes with effects.
271
+ * @param {string} selfShortcode - The note being compiled, so `map:` may be
272
+ * omitted for a reference within the same map.
273
+ * @returns {object} `{resolveRegionRef, resolveBehaviorRef, resolveEffectRef}`.
274
+ */
275
+ #resolvers(index, effectsByAddress, selfShortcode) {
276
+ const lookupRegion = (addr, label) => {
277
+ if (!addr || typeof addr !== "object" || !addr.region) {
278
+ throw new Error(
279
+ `${label}: a cross-reference is {map, region} — the target ` +
280
+ `map's shortcode and the region's key, never a UUID`,
281
+ );
282
+ }
283
+ const mapKey = addr.map ?? selfShortcode;
284
+ const target = index.get(mapKey);
285
+ if (!target) {
286
+ throw new Error(
287
+ `${label}: no map note has the shortcode "${mapKey}"`,
288
+ );
289
+ }
290
+ const region = target.regions.get(addr.region);
291
+ if (!region) {
292
+ throw new Error(
293
+ `${label}: map "${mapKey}" has no region "${addr.region}" — ` +
294
+ `it has ${[...target.regions.keys()].join(", ") || "none"}`,
295
+ );
296
+ }
297
+ return { target, region };
298
+ };
299
+
300
+ return {
301
+ resolveRegionRef: (addr, label) => {
302
+ const { target, region } = lookupRegion(addr, label);
303
+ return `Scene.${target.sceneId}.Region.${region.id}`;
304
+ },
305
+ resolveBehaviorRef: (addr, label) => {
306
+ const { target, region } = lookupRegion(addr, label);
307
+ if (!addr.behavior) {
308
+ throw new Error(
309
+ `${label}: a behaviour reference is {map, region, behavior}`,
310
+ );
311
+ }
312
+ const behaviorId = region.behaviors.get(addr.behavior);
313
+ if (!behaviorId) {
314
+ throw new Error(
315
+ `${label}: region "${addr.region}" has no behaviour ` +
316
+ `"${addr.behavior}"`,
317
+ );
318
+ }
319
+ return (
320
+ `Scene.${target.sceneId}.Region.${region.id}` +
321
+ `.RegionBehavior.${behaviorId}`
322
+ );
323
+ },
324
+ resolveEffectRef: (addr, label) => {
325
+ if (!addr || typeof addr !== "object" || !addr.item) {
326
+ throw new Error(
327
+ `${label}: an effect reference is {item, effect} — the ` +
328
+ `owning item's \`type-shortcode\` address and the ` +
329
+ `effect's name, never a UUID`,
330
+ );
331
+ }
332
+ const item = effectsByAddress.get(addr.item);
333
+ if (!item) {
334
+ throw new Error(
335
+ `${label}: no content note addressed "${addr.item}" ` +
336
+ `carries Active Effects`,
337
+ );
338
+ }
339
+ const effect = item.effects.find(
340
+ (e) => e.name === addr.effect || e._id === addr.effect,
341
+ );
342
+ if (!effect?._id) {
343
+ throw new Error(
344
+ `${label}: "${addr.item}" has no Active Effect named ` +
345
+ `"${addr.effect}" with an \`_id\``,
346
+ );
347
+ }
348
+ return `${compendiumUuid(foundryPackageId(), item.type, item.id, item.pack)}.ActiveEffect.${effect._id}`;
349
+ },
350
+ };
351
+ }
352
+
353
+ /**
354
+ * The heading keys a map pin may name, mapped to the page each compiled to.
355
+ *
356
+ * A location key matches a heading's `{#anchor}` slug, or the slug of its
357
+ * text. Both passes split the *converted* markdown, so the ids agree with
358
+ * the journals pack without either reading the other's output.
359
+ *
360
+ * @param {string} markdown - The converted body.
361
+ * @param {string} entryId - The JournalEntry's id.
362
+ * @param {string} name - The map's name (its lead page).
363
+ * @returns {Map<string, string>} heading key → page id.
364
+ */
365
+ #pageIds(markdown, entryId, name) {
366
+ const pageIds = new Map();
367
+ splitPages(markdown, name).forEach((page, index) => {
368
+ const id = journalPageId(entryId, page, index);
369
+ if (page.anchorSlug) pageIds.set(page.anchorSlug, id);
370
+ const slug = slugify(page.name);
371
+ if (slug && !pageIds.has(slug)) pageIds.set(slug, id);
372
+ });
373
+ return pageIds;
374
+ }
375
+
376
+ /**
377
+ * Index every cross-reference before the walk: a reference in the first
378
+ * note may address the last, and the effects an authored `to:` names live
379
+ * on item notes this pass does not otherwise read.
380
+ *
381
+ * @returns {Promise<void>}
382
+ */
383
+ async prepare() {
384
+ await super.prepare();
385
+ const { maps, effectsByAddress } = this.#collect();
386
+ this.index = this.#indexMaps(maps);
387
+ this.effectsByAddress = effectsByAddress;
388
+ this.knownActions = collectKnownActionNames(this.repoRoot);
389
+ /** place key → `{name, img, scenes: [], journal: []}` */
390
+ this.places = new Map();
391
+ }
392
+
393
+ /**
394
+ * Compile one map note into a Scene, and accumulate the place it belongs
395
+ * to — a map's Scene and the JournalEntry its prose compiles into ship
396
+ * together in an Adventure, which is the only import that preserves the
397
+ * ids its pins address.
398
+ *
399
+ * @param {object} fm - The note's frontmatter.
400
+ * @param {string} markdown - The body, tables expanded and wikilinks
401
+ * resolved.
402
+ * @returns {object} The Scene document.
403
+ */
404
+ compileNote(fm, markdown) {
405
+ const name = resolveName(fm);
406
+ const hasBody = Boolean(String(markdown).trim());
407
+ // The same doc-entry id the journals pass derives, from the
408
+ // shared `docEntryTypes` arrangement (#1514) — so neither
409
+ // pass has to read the other's output.
410
+ const entryId = hasBody ? itemDocEntryId(fm.id) : undefined;
411
+ const folder = this.folderResolver(sohlField(fm, "folder", null));
412
+ const warnings = [];
413
+ const scene = buildScene(fm, {
414
+ packageId: foundryPackageId(),
415
+ name,
416
+ folder,
417
+ stats: defaultStats(),
418
+ journalEntryId: entryId,
419
+ // A map note's prose is a derived JournalEntry: it lands in the
420
+ // default JournalEntry pack, not in whichever Scene pack the map
421
+ // itself was routed to (#1566).
422
+ journalPack: packRouter().defaultOf("JournalEntry"),
423
+ pageIds:
424
+ hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
425
+ knownActions: this.knownActions,
426
+ warnings,
427
+ ...this.#resolvers(this.index, this.effectsByAddress, fm.shortcode),
428
+ });
429
+ for (const message of warnings) {
430
+ // Named by file, like every other note diagnostic (#17). A map
431
+ // warning is about the note's frontmatter, which carries no
432
+ // offset, so it names the file and stops there rather than
433
+ // pointing at a line it cannot establish.
434
+ this.noteWarn(`map "${name}": ${message}`);
435
+ }
436
+ this.writeEntry(scene);
437
+
438
+ // The journal the pins point at, derived here exactly as the
439
+ // journals pass derives it, so the Adventure bundles the same
440
+ // document that pack ships.
441
+ const journal =
442
+ hasBody ?
443
+ buildJournalEntry({
444
+ id: entryId,
445
+ name,
446
+ markdown,
447
+ leadName: name,
448
+ folder: sohlField(fm, "folder", null),
449
+ flags: fm.flags,
450
+ })
451
+ : null;
452
+
453
+ const placeKey = sohlField(fm, "place", null) || fm.shortcode;
454
+ if (!this.places.has(placeKey)) {
455
+ this.places.set(placeKey, {
456
+ key: placeKey,
457
+ name: sohlField(fm, "placeName", null) || name,
458
+ img: sohlField(fm, "image", null),
459
+ pinned: false,
460
+ scenes: [],
461
+ journal: [],
462
+ });
463
+ }
464
+ const place = this.places.get(placeKey);
465
+ place.scenes.push(stripKeys(scene));
466
+ if (journal) place.journal.push(stripKeys(journal));
467
+ if (Object.keys(fm.sohl?.locations ?? {}).length) {
468
+ place.pinned = true;
469
+ }
470
+ return scene;
471
+ }
472
+
473
+ /**
474
+ * Bundle each pinned place into an Adventure, once every map of it has
475
+ * compiled.
476
+ *
477
+ * @returns {Promise<void>}
478
+ */
479
+ async finish() {
480
+ this.adventureCount = 0;
481
+ for (const place of this.places.values()) {
482
+ // A scene that references nothing ships fine as a plain `scenes`
483
+ // entry; only pins need the id-preserving import an Adventure gives.
484
+ if (!place.pinned) continue;
485
+ this.writeTo(this.adventureDir, this.#buildAdventure(place));
486
+ this.adventureCount++;
487
+ }
488
+ }
489
+
490
+ /** @inheritdoc */
491
+ reportCompiled(stats) {
492
+ log.info(
493
+ `Compiled ${stats.compiled} scene(s) and ${this.adventureCount} adventure(s)`,
494
+ );
495
+ }
496
+
497
+ /**
498
+ * Bundle one place's scenes and journals into an Adventure.
499
+ *
500
+ * @param {object} place - The accumulated place.
501
+ * @returns {object} The Adventure document, keyed for the pack.
502
+ */
503
+ #buildAdventure(place) {
504
+ const id = makeId("map-adventure", place.key);
505
+ return {
506
+ name: place.name,
507
+ img: place.img ?? null,
508
+ caption: "",
509
+ description: "",
510
+ actors: [],
511
+ combats: [],
512
+ items: [],
513
+ journal: place.journal,
514
+ scenes: place.scenes,
515
+ tables: [],
516
+ macros: [],
517
+ cards: [],
518
+ playlists: [],
519
+ folders: [],
520
+ folder: null,
521
+ sort: 0,
522
+ flags: {},
523
+ _id: id,
524
+ _stats: defaultStats(),
525
+ _key: `!adventures!${id}`,
526
+ };
527
+ }
528
+ }