@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,1302 @@
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
+ * **Map notes** — the markdown → Foundry `Scene` translation (issue #1525).
16
+ *
17
+ * A map note carries an *essence*: a curated, hand-owned subset of what a Scene
18
+ * record holds, exactly as a weapon note carries a weapon's essence rather than
19
+ * an Item's schema. Everything a Scene needs and nobody should have to author —
20
+ * the canvas defaults, the embedded `Level`, every derived region field — is
21
+ * synthesised here.
22
+ *
23
+ * Three note types compile through this module and differ only in derived
24
+ * defaults: `battlemap` (tactical), `localmap` (~1 km) and `regionalmap` (large
25
+ * scale). An unknown type fails the build rather than silently defaulting.
26
+ *
27
+ * **Two unit conventions, deliberately.** Geometry — walls, doors, lights,
28
+ * tiles, sounds, region shapes — is authored in **pixels**, Foundry's native
29
+ * storage, because a traced battlemap's walls do not lie on grid intersections
30
+ * (measured: 97.8% do not). Map pins (`locations:`) are authored in **grid
31
+ * squares**, commonly half-integers, because that is how a human reads a
32
+ * position off the map. The two are told apart by their key: `position:` and
33
+ * segment/shape coordinates are pixels, `at:` is grid squares. Mixing them
34
+ * fails silently and visually in Foundry — a grid-valued wall lands in a tiny
35
+ * clump at the top-left — so both directions are linted here against the map's
36
+ * own `dimensions` and `pxPerGrid`.
37
+ *
38
+ * **What this module refuses.** Foundry accepts several authoring mistakes
39
+ * without complaint, producing a valid document that simply never does
40
+ * anything: a region event outside SoHL's curated set, a region with no shapes,
41
+ * a degenerate two-point polygon, `restrict:` with no level. Each is an error
42
+ * here. So is any `executeScript` behaviour: its `source` is a
43
+ * `JavaScriptField`, and a note carrying one would be data compiled into code —
44
+ * the system's top security constraint. It is not representable in this schema
45
+ * and there is no escape hatch.
46
+ *
47
+ * Plain ESM with no Foundry and no filesystem access, so it is unit-testable
48
+ * and usable from the bare-`node` pack scripts. The compiler that walks the
49
+ * content tree and drives it is `scenes.mjs`.
50
+ */
51
+
52
+ import crypto from "crypto";
53
+
54
+ import { compendiumUuid, makeId, MAP_TYPES } from "./ids.mjs";
55
+ // The curated region-event vocabulary is shared verbatim with the runtime
56
+ // bridge (`SohlRegionTriggerBehavior`), so an event this build accepts is
57
+ // exactly one the bridge forwards.
58
+ import {
59
+ CURATED_REGION_EVENTS,
60
+ EXCLUDED_REGION_EVENTS,
61
+ } from "./region-events.mjs";
62
+
63
+ /* -------------------------------------------------------------------- */
64
+ /* Note types and their canvas profiles */
65
+ /* -------------------------------------------------------------------- */
66
+
67
+ // The set itself lives in `ids.mjs`, a leaf both this module and the
68
+ // doc-carrying type set in `item-docs.mjs` can depend on without a cycle.
69
+ export { MAP_TYPES };
70
+
71
+ /**
72
+ * Whether a content note's type compiles into a Scene.
73
+ *
74
+ * @param {string} [type] - The note's `type` frontmatter.
75
+ * @returns {boolean} True for a map type.
76
+ */
77
+ export function isMapType(type) {
78
+ return MAP_TYPES.has(String(type));
79
+ }
80
+
81
+ /**
82
+ * Per-type canvas defaults, emitted **explicitly** on every scene.
83
+ *
84
+ * This is not a convenience. `grid.type`, `grid.distance` and `grid.units` all
85
+ * declare `initial: () => game.system.grid.*`, and there is no `game` at build
86
+ * time — left to their initial they would throw or ship undefined. The numeric
87
+ * values are `CONST.GRID_TYPES` and `CONST.FOG_EXPLORATION_MODES`, spelled out
88
+ * here because the constants module is Foundry's, not ours.
89
+ *
90
+ * @type {Readonly<Record<string, object>>}
91
+ */
92
+ export const MAP_TYPE_PROFILES = Object.freeze({
93
+ battlemap: Object.freeze({
94
+ grid: { type: 1 /* SQUARE */, distance: 5, units: "ft" },
95
+ tokenVision: true,
96
+ fog: { mode: 1 /* INDIVIDUAL */ },
97
+ padding: 0.25,
98
+ }),
99
+ localmap: Object.freeze({
100
+ grid: { type: 1 /* SQUARE */, distance: 10, units: "m" },
101
+ tokenVision: false,
102
+ fog: { mode: 0 /* DISABLED */ },
103
+ padding: 0.1,
104
+ }),
105
+ regionalmap: Object.freeze({
106
+ grid: { type: 0 /* GRIDLESS */, distance: 5, units: "km" },
107
+ tokenVision: false,
108
+ fog: { mode: 0 /* DISABLED */ },
109
+ padding: 0,
110
+ }),
111
+ });
112
+
113
+ /**
114
+ * The canvas profile for a map type.
115
+ *
116
+ * @param {string} type - The note's `type`.
117
+ * @returns {object} The profile from {@link MAP_TYPE_PROFILES}.
118
+ * @throws {Error} When the type is not a map type — the build's fail-fast
119
+ * contract, so a typo never ships a scene with Foundry's own defaults.
120
+ */
121
+ export function mapProfile(type) {
122
+ const profile = MAP_TYPE_PROFILES[String(type)];
123
+ if (!profile) {
124
+ throw new Error(
125
+ `unknown map type "${type}" — expected one of ${[...MAP_TYPES].join(", ")}`,
126
+ );
127
+ }
128
+ return profile;
129
+ }
130
+
131
+ /**
132
+ * Foundry's own id for the level a scene is created with
133
+ * (`Scene.metadata.defaultLevelId`). Adopting it makes every reference to the
134
+ * synthesised level derivable, and matches what Foundry would have produced.
135
+ */
136
+ export const DEFAULT_LEVEL_ID = "defaultLevel0000";
137
+
138
+ /* -------------------------------------------------------------------- */
139
+ /* Derived ids and colours */
140
+ /* -------------------------------------------------------------------- */
141
+
142
+ /**
143
+ * The id of one region within its scene.
144
+ *
145
+ * Derived rather than stored so a cross-reference (`teleportToken`,
146
+ * `toggleBehavior`) can be resolved to a UUID before the target scene has been
147
+ * compiled. An authored `_id` always wins — the converter pins ids on
148
+ * write-back exactly as it does for items and actors.
149
+ *
150
+ * @param {string} sceneId - The owning scene's `_id`.
151
+ * @param {string} key - The region's authored key.
152
+ * @param {string} [pinned] - An authored `_id`, if any.
153
+ * @returns {string} A 16-character Foundry id.
154
+ */
155
+ export function regionDocId(sceneId, key, pinned) {
156
+ return pinned || makeId("scene-region", `${sceneId}:${key}`);
157
+ }
158
+
159
+ /**
160
+ * The id of one behaviour within its region.
161
+ *
162
+ * @param {string} regionId - The owning region's `_id`.
163
+ * @param {string} key - The behaviour's authored key.
164
+ * @param {string} [pinned] - An authored `_id`, if any.
165
+ * @returns {string} A 16-character Foundry id.
166
+ */
167
+ export function behaviorDocId(regionId, key, pinned) {
168
+ return pinned || makeId("region-behavior", `${regionId}:${key}`);
169
+ }
170
+
171
+ /**
172
+ * A region's highlight colour, derived from its key.
173
+ *
174
+ * Foundry's default is `Color.fromHSV([Math.random(), 0.8, 0.8])` — a different
175
+ * value on every create, which would make each build's output differ from the
176
+ * last for no reason anyone could see. Hashing the key keeps builds
177
+ * reproducible and diffs quiet, in the same hue/saturation family Foundry
178
+ * itself would have chosen, and keeps the value out of the note entirely.
179
+ *
180
+ * @param {string} key - The region's authored key.
181
+ * @returns {string} A CSS hex colour.
182
+ */
183
+ export function regionColor(key) {
184
+ const digest = crypto.createHash("sha1").update(`region:${key}`).digest();
185
+ const hue = digest.readUInt32BE(0) / 0x100000000;
186
+ return hsvToHex(hue, 0.8, 0.8);
187
+ }
188
+
189
+ /**
190
+ * HSV → `#rrggbb`, matching Foundry's `Color.fromHSV`.
191
+ *
192
+ * @param {number} h - Hue, 0–1.
193
+ * @param {number} s - Saturation, 0–1.
194
+ * @param {number} v - Value, 0–1.
195
+ * @returns {string} A CSS hex colour.
196
+ */
197
+ function hsvToHex(h, s, v) {
198
+ const i = Math.floor(h * 6);
199
+ const f = h * 6 - i;
200
+ const p = v * (1 - s);
201
+ const q = v * (1 - f * s);
202
+ const t = v * (1 - (1 - f) * s);
203
+ const [r, g, b] = [
204
+ [v, t, p],
205
+ [q, v, p],
206
+ [p, v, t],
207
+ [p, q, v],
208
+ [t, p, v],
209
+ [v, p, q],
210
+ ][i % 6];
211
+ const byte = (c) =>
212
+ Math.round(c * 255)
213
+ .toString(16)
214
+ .padStart(2, "0");
215
+ return `#${byte(r)}${byte(g)}${byte(b)}`;
216
+ }
217
+
218
+ /* -------------------------------------------------------------------- */
219
+ /* Unit-mix lints */
220
+ /* -------------------------------------------------------------------- */
221
+
222
+ /**
223
+ * The map's own measurements, from which both unit lints are derived.
224
+ *
225
+ * @typedef {object} MapGeometry
226
+ * @property {string} label - The authored key, for error messages.
227
+ * @property {number} pxPerGrid - Pixels per grid square.
228
+ * @property {number[]} dimensions - `[width, height]` in pixels.
229
+ */
230
+
231
+ /**
232
+ * Reject geometry authored in grid squares where pixels belong.
233
+ *
234
+ * A grid-valued wall or region is not rejected by Foundry: it becomes a
235
+ * 12x11-pixel feature in the top-left corner, invisible on the map and
236
+ * impossible to notice in a diff. The test is derived from the map itself
237
+ * rather than a magic number — when every coordinate of one feature is smaller
238
+ * than a single grid square, on a map several squares across, the author
239
+ * counted squares.
240
+ *
241
+ * @param {number[]} coords - The feature's coordinates, in pixels.
242
+ * @param {MapGeometry} geom - The map's measurements.
243
+ * @throws {Error} When the coordinates read as grid units.
244
+ */
245
+ export function assertPixelGeometry(coords, geom) {
246
+ const values = coords.filter((c) => Number.isFinite(c)).map(Math.abs);
247
+ if (!values.length) return;
248
+ const gridSpan = Math.max(...geom.dimensions) / geom.pxPerGrid;
249
+ const largest = Math.max(...values);
250
+ if (gridSpan >= 4 && largest > 0 && largest < geom.pxPerGrid) {
251
+ throw new Error(
252
+ `${geom.label}: every coordinate is smaller than one grid square ` +
253
+ `(${geom.pxPerGrid}px) on a map ${Math.round(gridSpan)} squares ` +
254
+ `across — this geometry looks authored in grid units. Wall, door, ` +
255
+ `light, tile, sound and region-shape coordinates are pixels; only ` +
256
+ `\`locations:\` are grid squares.`,
257
+ );
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Reject a map pin authored in pixels where grid squares belong.
263
+ *
264
+ * @param {number[]} at - The pin's `[x, y]`, in grid squares.
265
+ * @param {MapGeometry} geom - The map's measurements.
266
+ * @throws {Error} When the coordinates read as pixels.
267
+ */
268
+ export function assertGridLocation(at, geom) {
269
+ const [gx, gy] = [
270
+ geom.dimensions[0] / geom.pxPerGrid,
271
+ geom.dimensions[1] / geom.pxPerGrid,
272
+ ];
273
+ if (at[0] > gx || at[1] > gy) {
274
+ throw new Error(
275
+ `${geom.label}: [${at.join(", ")}] lies outside the map's ` +
276
+ `${Math.round(gx)}x${Math.round(gy)} grid — a location looks ` +
277
+ `authored in pixels. \`locations:\` are grid squares (commonly ` +
278
+ `half-integers, a pin centred in its square).`,
279
+ );
280
+ }
281
+ }
282
+
283
+ /* -------------------------------------------------------------------- */
284
+ /* Walls and doors */
285
+ /* -------------------------------------------------------------------- */
286
+
287
+ /** Authored sense names → the Wall field each restricts. */
288
+ const SENSE_FIELDS = Object.freeze({
289
+ movement: "move",
290
+ sight: "sight",
291
+ light: "light",
292
+ sound: "sound",
293
+ });
294
+
295
+ /** `CONST.EDGE_SENSE_TYPES`. `WALL_SENSE_TYPES` is deprecated in v14. */
296
+ const EDGE_SENSE = Object.freeze({ NONE: 0, LIMITED: 10, NORMAL: 20 });
297
+
298
+ /**
299
+ * Compile a wall's `blocks:` / `limits:` lists into Foundry's four numeric
300
+ * restriction fields.
301
+ *
302
+ * The authored vocabulary is deliberately not Foundry's.
303
+ * `WALL_MOVEMENT_TYPES.NONE` means movement does **not** collide — i.e.
304
+ * passable — so `movement: none` reads as the exact opposite of what it does.
305
+ * `blocks:` says what the wall stops and `limits:` what it merely attenuates;
306
+ * anything unnamed is passable.
307
+ *
308
+ * @param {{blocks?: string[], limits?: string[]}} spec - The authored lists.
309
+ * @param {string} label - The authored key, for error messages.
310
+ * @returns {{move: number, sight: number, light: number, sound: number}} The
311
+ * Wall restriction fields.
312
+ * @throws {Error} On an unknown sense, a sense in both lists, or a *limited*
313
+ * movement restriction (movement is binary — it has no LIMITED value).
314
+ */
315
+ export function wallRestrictions(spec, label) {
316
+ const out = { move: 0, sight: 0, light: 0, sound: 0 };
317
+ const blocks = toList(spec?.blocks);
318
+ const limits = toList(spec?.limits);
319
+
320
+ const check = (name, list) => {
321
+ if (!(name in SENSE_FIELDS)) {
322
+ throw new Error(
323
+ `${label}: unknown restriction "${name}" in ${list} — expected ` +
324
+ `one of ${Object.keys(SENSE_FIELDS).join(", ")}`,
325
+ );
326
+ }
327
+ };
328
+ for (const name of blocks) check(name, "blocks");
329
+ for (const name of limits) check(name, "limits");
330
+
331
+ const both = blocks.filter((n) => limits.includes(n));
332
+ if (both.length) {
333
+ throw new Error(
334
+ `${label}: "${both[0]}" is named in both blocks and limits — a ` +
335
+ `restriction is one or the other`,
336
+ );
337
+ }
338
+ if (limits.includes("movement")) {
339
+ throw new Error(
340
+ `${label}: movement cannot be limited — a wall either blocks ` +
341
+ `movement or does not. Name it in blocks, or leave it out.`,
342
+ );
343
+ }
344
+
345
+ for (const name of blocks) out[SENSE_FIELDS[name]] = EDGE_SENSE.NORMAL;
346
+ for (const name of limits) out[SENSE_FIELDS[name]] = EDGE_SENSE.LIMITED;
347
+ return out;
348
+ }
349
+
350
+ /** `CONST.WALL_DOOR_TYPES` by authored name. */
351
+ const DOOR_KINDS = Object.freeze({ door: 1, secret: 2 });
352
+
353
+ /** `CONST.WALL_DOOR_STATES` by authored name. */
354
+ const DOOR_STATES = Object.freeze({ closed: 0, open: 1, locked: 2 });
355
+
356
+ /**
357
+ * Compile one wall segment into a Wall document.
358
+ *
359
+ * @param {number[]} segment - `[x1, y1, x2, y2]` in pixels.
360
+ * @param {object} spec - The authored wall or door entry.
361
+ * @param {MapGeometry} geom - The map's measurements.
362
+ * @param {{sceneId: string, id: string}} ids - The scene id and this wall's id.
363
+ * @returns {object} The Wall document, keyed for the pack.
364
+ */
365
+ function buildWall(segment, spec, geom, { sceneId, id }) {
366
+ if (!Array.isArray(segment) || segment.length !== 4) {
367
+ throw new Error(
368
+ `${geom.label}: a wall segment is [x1, y1, x2, y2] in pixels; got ` +
369
+ `${JSON.stringify(segment)}`,
370
+ );
371
+ }
372
+ assertPixelGeometry(segment, geom);
373
+ const doc = {
374
+ _id: id,
375
+ c: segment.map((n) => Math.round(n)),
376
+ ...wallRestrictions(spec, geom.label),
377
+ dir: 0,
378
+ door: 0,
379
+ ds: 0,
380
+ _key: `!scenes.walls!${sceneId}.${id}`,
381
+ };
382
+ if (spec.kind) {
383
+ const door = DOOR_KINDS[spec.kind];
384
+ if (door === undefined) {
385
+ throw new Error(
386
+ `${geom.label}: unknown door kind "${spec.kind}" — expected ` +
387
+ `${Object.keys(DOOR_KINDS).join(" or ")}`,
388
+ );
389
+ }
390
+ doc.door = door;
391
+ const state = DOOR_STATES[spec.state ?? "closed"];
392
+ if (state === undefined) {
393
+ throw new Error(
394
+ `${geom.label}: unknown door state "${spec.state}" — expected ` +
395
+ `one of ${Object.keys(DOOR_STATES).join(", ")}`,
396
+ );
397
+ }
398
+ doc.ds = state;
399
+ }
400
+ return doc;
401
+ }
402
+
403
+ /* -------------------------------------------------------------------- */
404
+ /* Region shapes */
405
+ /* -------------------------------------------------------------------- */
406
+
407
+ /**
408
+ * The shape forms a map note may author. `rect` is the short spelling of
409
+ * `rectangle`, as the design's own examples use.
410
+ */
411
+ const SHAPE_FORMS = Object.freeze(
412
+ new Set(["rect", "rectangle", "circle", "ellipse", "polygon"]),
413
+ );
414
+
415
+ /**
416
+ * Compile one authored shape into a Foundry shape record.
417
+ *
418
+ * Four forms only — `rectangle` (`rect` is accepted as its short spelling),
419
+ * `circle`, `ellipse` and `polygon`. The remaining six Foundry shapes are
420
+ * template and token-attached forms with no place in an authored map, and
421
+ * `gridBased` is deliberately not exposed: it re-interprets a shape metrically,
422
+ * and one unit convention per note is the whole point.
423
+ *
424
+ * @param {object} spec - The authored shape entry.
425
+ * @param {MapGeometry} geom - The map's measurements.
426
+ * @returns {object} The Foundry shape record.
427
+ * @throws {Error} On an unknown form, a degenerate polygon, or grid-unit
428
+ * coordinates.
429
+ */
430
+ export function buildShape(spec, geom) {
431
+ if (!spec || typeof spec !== "object") {
432
+ throw new Error(`${geom.label}: a shape must be a mapping`);
433
+ }
434
+ const hole = Boolean(spec.hole);
435
+ const forms = Object.keys(spec).filter((k) => k !== "hole");
436
+ if (forms.length !== 1) {
437
+ throw new Error(
438
+ `${geom.label}: a shape names exactly one of rectangle, circle, ` +
439
+ `ellipse, polygon; got ${forms.length ? forms.join(" + ") : "none"}`,
440
+ );
441
+ }
442
+ const [form] = forms;
443
+ if (!SHAPE_FORMS.has(form)) {
444
+ throw new Error(
445
+ `${geom.label}: unsupported shape "${form}" — a map note may use ` +
446
+ `rectangle, circle, ellipse or polygon`,
447
+ );
448
+ }
449
+ const raw = spec[form];
450
+ if (!Array.isArray(raw) || raw.some((n) => !Number.isFinite(n))) {
451
+ throw new Error(
452
+ `${geom.label}: ${form} takes a flat list of numbers in pixels`,
453
+ );
454
+ }
455
+ assertPixelGeometry(raw, geom);
456
+
457
+ switch (form) {
458
+ case "rect":
459
+ case "rectangle":
460
+ expectLength(
461
+ raw,
462
+ 4,
463
+ `${geom.label}: rectangle is [x, y, width, height]`,
464
+ );
465
+ return {
466
+ type: "rectangle",
467
+ x: raw[0],
468
+ y: raw[1],
469
+ width: raw[2],
470
+ height: raw[3],
471
+ hole,
472
+ };
473
+ case "circle":
474
+ expectLength(raw, 3, `${geom.label}: circle is [x, y, radius]`);
475
+ return {
476
+ type: "circle",
477
+ x: raw[0],
478
+ y: raw[1],
479
+ radius: raw[2],
480
+ hole,
481
+ };
482
+ case "ellipse":
483
+ if (raw.length !== 4 && raw.length !== 5) {
484
+ throw new Error(
485
+ `${geom.label}: ellipse is [x, y, radiusX, radiusY] with an ` +
486
+ `optional rotation`,
487
+ );
488
+ }
489
+ return {
490
+ type: "ellipse",
491
+ x: raw[0],
492
+ y: raw[1],
493
+ radiusX: raw[2],
494
+ radiusY: raw[3],
495
+ rotation: raw[4] ?? 0,
496
+ hole,
497
+ };
498
+ case "polygon":
499
+ if (raw.length % 2 !== 0) {
500
+ throw new Error(
501
+ `${geom.label}: a polygon takes x/y pairs, so an even count; ` +
502
+ `got ${raw.length}`,
503
+ );
504
+ }
505
+ // The schema's floor of 4 numbers admits a two-point "polygon",
506
+ // which is a line segment and encloses nothing.
507
+ if (raw.length < 6) {
508
+ throw new Error(
509
+ `${geom.label}: a polygon needs at least 3 points; got ` +
510
+ `${raw.length / 2}`,
511
+ );
512
+ }
513
+ return { type: "polygon", points: [...raw], hole };
514
+ default:
515
+ throw new Error(
516
+ `${geom.label}: unsupported shape "${form}" — a map note may use ` +
517
+ `rectangle, circle, ellipse or polygon`,
518
+ );
519
+ }
520
+ }
521
+
522
+ /* -------------------------------------------------------------------- */
523
+ /* Region behaviours */
524
+ /* -------------------------------------------------------------------- */
525
+
526
+ /**
527
+ * The events `toggleBehavior` itself accepts — Foundry's own list, which is
528
+ * wider than SoHL's curated set.
529
+ */
530
+ const TOGGLE_BEHAVIOR_EVENTS = Object.freeze([
531
+ "tokenEnter",
532
+ "tokenExit",
533
+ "tokenMoveIn",
534
+ "tokenMoveOut",
535
+ "tokenTurnStart",
536
+ "tokenTurnEnd",
537
+ "tokenRoundStart",
538
+ "tokenRoundEnd",
539
+ ]);
540
+
541
+ /** `AdjustDarknessLevelRegionBehaviorType.MODES`, authored by name. */
542
+ const DARKNESS_MODES = Object.freeze({ override: 0, brighten: 1, darken: 2 });
543
+
544
+ /** The events `displayScrollingText` accepts. */
545
+ const SCROLLING_TEXT_EVENTS = Object.freeze([
546
+ "tokenAnimateIn",
547
+ "tokenAnimateOut",
548
+ "tokenTurnStart",
549
+ "tokenTurnEnd",
550
+ "tokenRoundStart",
551
+ "tokenRoundEnd",
552
+ ]);
553
+
554
+ /**
555
+ * The region behaviours a map note may carry, and the fields each accepts.
556
+ *
557
+ * An allow-list rather than a pass-through: an unlisted field would be dropped
558
+ * by Foundry without a word, and an unlisted *type* fails Foundry's own create
559
+ * with an unrelated message (`Cannot read properties of undefined (reading
560
+ * 'regions')`), so both are checked here where the note and the key can be
561
+ * named.
562
+ *
563
+ * @type {Readonly<Record<string, {fields: string[], events?: readonly string[]}>>}
564
+ */
565
+ const BEHAVIOR_SPECS = Object.freeze({
566
+ // The SoHL bridge (#593).
567
+ trigger: { fields: ["events", "action"], events: CURATED_REGION_EVENTS },
568
+ adjustDarknessLevel: { fields: ["mode", "modifier"] },
569
+ applyActiveEffect: { fields: ["effects"] },
570
+ changeLevel: { fields: ["movementActions"] },
571
+ defineSurface: {
572
+ fields: [
573
+ "placement",
574
+ "light",
575
+ "move",
576
+ "sight",
577
+ "sound",
578
+ "occlusion",
579
+ "exposure",
580
+ "culling",
581
+ ],
582
+ },
583
+ displayScrollingText: {
584
+ fields: ["events", "text", "color", "visibility", "once"],
585
+ events: SCROLLING_TEXT_EVENTS,
586
+ },
587
+ modifyMovementCost: { fields: ["difficulties"] },
588
+ pauseGame: { fields: ["once"] },
589
+ suppressWeather: { fields: [] },
590
+ teleportToken: {
591
+ fields: ["to", "placement", "snap", "choice", "revealed"],
592
+ },
593
+ toggleBehavior: {
594
+ fields: ["events", "enable", "disable"],
595
+ events: TOGGLE_BEHAVIOR_EVENTS,
596
+ },
597
+ });
598
+
599
+ /**
600
+ * The behaviour types a map note may carry (issue #1525, v1).
601
+ *
602
+ * @type {ReadonlySet<string>}
603
+ */
604
+ export const REGION_BEHAVIOR_TYPES = Object.freeze(
605
+ new Set(Object.keys(BEHAVIOR_SPECS)),
606
+ );
607
+
608
+ /**
609
+ * Behaviour types a map note may **never** carry, and why.
610
+ *
611
+ * `executeScript` is banned outright: its `source` is a `JavaScriptField`, so a
612
+ * note carrying one would compile data into code — the system's top security
613
+ * constraint (non-negotiable rule 10). There is no escape hatch and no
614
+ * configuration that re-enables it. `executeMacro` is merely deferred: its
615
+ * target Macro has to ship in the same Adventure to resolve, and
616
+ * Adventure-bundled macros are not built yet.
617
+ *
618
+ * @type {ReadonlyMap<string, string>}
619
+ */
620
+ export const BANNED_REGION_BEHAVIOR_TYPES = Object.freeze(
621
+ new Map([
622
+ [
623
+ "executeScript",
624
+ "its `source` is a JavaScriptField, so a note carrying one would " +
625
+ "compile data into code (non-negotiable rule 10). It is not " +
626
+ "representable in a map note and has no escape hatch.",
627
+ ],
628
+ [
629
+ "executeMacro",
630
+ "its target Macro must ship in the same Adventure to resolve, and " +
631
+ "Adventure-bundled macros are not built yet (deferred from #1525).",
632
+ ],
633
+ ]),
634
+ );
635
+
636
+ /**
637
+ * Compile one authored behaviour entry into a RegionBehavior document.
638
+ *
639
+ * @param {string} key - The behaviour's authored key.
640
+ * @param {object} spec - The authored entry.
641
+ * @param {object} ctx - The compile context (resolvers, warnings, ids).
642
+ * @returns {object} The RegionBehavior document, keyed for the pack.
643
+ */
644
+ function buildBehavior(key, spec, ctx) {
645
+ const label = `${ctx.regionLabel}.behaviors.${key}`;
646
+ if (!spec || typeof spec !== "object") {
647
+ throw new Error(`${label}: a behaviour must be a mapping`);
648
+ }
649
+ const reserved = new Set(["_id", "name", "disabled"]);
650
+ const types = Object.keys(spec).filter((k) => !reserved.has(k));
651
+ if (types.length !== 1) {
652
+ throw new Error(
653
+ `${label}: a behaviour names exactly one behaviour type; got ` +
654
+ `${types.length ? types.join(" + ") : "none"}`,
655
+ );
656
+ }
657
+ const [type] = types;
658
+
659
+ const banned = BANNED_REGION_BEHAVIOR_TYPES.get(type);
660
+ if (banned)
661
+ throw new Error(`${label}: "${type}" is not permitted — ${banned}`);
662
+ const behaviorSpec = BEHAVIOR_SPECS[type];
663
+ if (!behaviorSpec) {
664
+ throw new Error(
665
+ `${label}: unknown behaviour type "${type}" — a map note may carry ` +
666
+ `${[...REGION_BEHAVIOR_TYPES].sort().join(", ")}`,
667
+ );
668
+ }
669
+
670
+ const authored = spec[type] ?? {};
671
+ if (typeof authored !== "object" || Array.isArray(authored)) {
672
+ throw new Error(`${label}: ${type} takes a mapping of its fields`);
673
+ }
674
+ for (const field of Object.keys(authored)) {
675
+ if (!behaviorSpec.fields.includes(field)) {
676
+ throw new Error(
677
+ `${label}: ${type} has no field "${field}" — it accepts ` +
678
+ `${behaviorSpec.fields.join(", ") || "no fields"}`,
679
+ );
680
+ }
681
+ }
682
+
683
+ const system = compileBehaviorSystem(
684
+ type,
685
+ authored,
686
+ behaviorSpec,
687
+ label,
688
+ ctx,
689
+ );
690
+ const id = behaviorDocId(ctx.regionId, key, spec._id);
691
+ const doc = {
692
+ _id: id,
693
+ name: spec.name ?? "",
694
+ type,
695
+ system,
696
+ _key: `!scenes.regions.behaviors!${ctx.sceneId}.${ctx.regionId}.${id}`,
697
+ };
698
+ if (spec.disabled) doc.disabled = true;
699
+ return doc;
700
+ }
701
+
702
+ /**
703
+ * Translate one behaviour's authored fields into its `system` data.
704
+ *
705
+ * @param {string} type - The behaviour type.
706
+ * @param {object} authored - The authored fields.
707
+ * @param {object} behaviorSpec - Its entry in {@link BEHAVIOR_SPECS}.
708
+ * @param {string} label - The authored path, for error messages.
709
+ * @param {object} ctx - The compile context.
710
+ * @returns {object} The behaviour's `system` data.
711
+ */
712
+ function compileBehaviorSystem(type, authored, behaviorSpec, label, ctx) {
713
+ if (behaviorSpec.events) {
714
+ assertEvents(authored.events, behaviorSpec.events, label, type);
715
+ }
716
+ switch (type) {
717
+ case "trigger": {
718
+ if (authored.action && !ctx.knownActions?.has(authored.action)) {
719
+ ctx.warn(
720
+ `${label}: action "${authored.action}" matches no known SoHL ` +
721
+ `action — the region will forward the event but offer nothing`,
722
+ );
723
+ }
724
+ return {
725
+ events: [...authored.events],
726
+ actionName: authored.action ?? null,
727
+ };
728
+ }
729
+ case "teleportToken": {
730
+ const { to, ...rest } = authored;
731
+ return {
732
+ ...rest,
733
+ destinations: toList(to).map((addr) =>
734
+ ctx.resolveRegionRef(addr, label),
735
+ ),
736
+ };
737
+ }
738
+ case "toggleBehavior": {
739
+ const { enable, disable, ...rest } = authored;
740
+ const out = { ...rest, events: [...authored.events] };
741
+ if (enable) {
742
+ out.enable = toList(enable).map((a) =>
743
+ ctx.resolveBehaviorRef(a, label),
744
+ );
745
+ }
746
+ if (disable) {
747
+ out.disable = toList(disable).map((a) =>
748
+ ctx.resolveBehaviorRef(a, label),
749
+ );
750
+ }
751
+ return out;
752
+ }
753
+ case "adjustDarknessLevel": {
754
+ // Authored by name: `AdjustDarknessLevelRegionBehaviorType.MODES`
755
+ // is a numeric enum nobody should have to remember.
756
+ const mode = authored.mode ?? "override";
757
+ const value = DARKNESS_MODES[mode];
758
+ if (value === undefined) {
759
+ throw new Error(
760
+ `${label}: unknown darkness mode "${mode}" — expected one of ` +
761
+ `${Object.keys(DARKNESS_MODES).join(", ")}`,
762
+ );
763
+ }
764
+ return { mode: value, modifier: authored.modifier ?? 0 };
765
+ }
766
+ case "applyActiveEffect": {
767
+ return {
768
+ effects: toList(authored.effects).map((a) =>
769
+ ctx.resolveEffectRef(a, label),
770
+ ),
771
+ };
772
+ }
773
+ default:
774
+ return { ...authored };
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Reject a region event Foundry would store verbatim and never fire.
780
+ *
781
+ * The excluded names are listed in the error because reaching for one — most
782
+ * plausibly `tokenMoveWithin` — is the mistake this lint exists for, and
783
+ * Foundry gives no sign of it: a bogus name never matches a dispatched event,
784
+ * and an excluded one is dispatched and then dropped by the bridge. No error,
785
+ * no log, no automation.
786
+ *
787
+ * @param {string[]} events - The authored events.
788
+ * @param {readonly string[]} allowed - The events this behaviour accepts.
789
+ * @param {string} label - The authored path, for error messages.
790
+ * @param {string} type - The behaviour type.
791
+ * @throws {Error} When an event is not accepted.
792
+ */
793
+ function assertEvents(events, allowed, label, type) {
794
+ const list = toList(events);
795
+ if (!list.length) {
796
+ throw new Error(`${label}: ${type} needs at least one event`);
797
+ }
798
+ for (const event of list) {
799
+ if (allowed.includes(event)) continue;
800
+ const excluded = EXCLUDED_REGION_EVENTS.includes(event);
801
+ throw new Error(
802
+ `${label}: "${event}" is ${excluded ? "deliberately excluded" : "not a region event"} ` +
803
+ `for ${type}. Accepted: ${allowed.join(", ")}. Excluded (stored ` +
804
+ `verbatim by Foundry and never acted on): ` +
805
+ `${EXCLUDED_REGION_EVENTS.join(", ")}.`,
806
+ );
807
+ }
808
+ }
809
+
810
+ /* -------------------------------------------------------------------- */
811
+ /* Regions */
812
+ /* -------------------------------------------------------------------- */
813
+
814
+ /** `CONST.REGION_VISIBILITY`, authored by name because the order is arbitrary. */
815
+ const REGION_VISIBILITY = Object.freeze({
816
+ layer: 0,
817
+ gamemaster: 1,
818
+ always: 2,
819
+ observer: 3,
820
+ layerUnlocked: 4,
821
+ });
822
+
823
+ /** `CONST.EDGE_RESTRICTION_TYPES`. */
824
+ const RESTRICTION_TYPES = Object.freeze([
825
+ "light",
826
+ "darkness",
827
+ "sight",
828
+ "sound",
829
+ "move",
830
+ ]);
831
+
832
+ /**
833
+ * Compile one authored region into a Region document with its behaviours.
834
+ *
835
+ * @param {string} key - The region's authored key.
836
+ * @param {object} spec - The authored entry.
837
+ * @param {MapGeometry} geom - The map's measurements.
838
+ * @param {object} ctx - The compile context.
839
+ * @returns {object} The Region document, keyed for the pack.
840
+ */
841
+ function buildRegion(key, spec, geom, ctx) {
842
+ const label = `regions.${key}`;
843
+ const shapeGeom = { ...geom, label };
844
+ const shapes = toList(spec.shapes).map((s) => buildShape(s, shapeGeom));
845
+ if (!shapes.length) {
846
+ throw new Error(
847
+ `${label}: a region needs at least one shape — Foundry accepts one ` +
848
+ `with none, and it simply never triggers`,
849
+ );
850
+ }
851
+
852
+ const id = regionDocId(ctx.sceneId, key, spec._id);
853
+ const visibilityName = spec.visibility ?? "layerUnlocked";
854
+ const visibility = REGION_VISIBILITY[visibilityName];
855
+ if (visibility === undefined) {
856
+ throw new Error(
857
+ `${label}: unknown visibility "${visibilityName}" — expected one of ` +
858
+ `${Object.keys(REGION_VISIBILITY).join(", ")}`,
859
+ );
860
+ }
861
+
862
+ const [bottom = null, top = null] = toList(spec.elevation);
863
+ const doc = {
864
+ _id: id,
865
+ name: spec.name ?? key,
866
+ color: regionColor(key),
867
+ shapes,
868
+ elevation: { bottom: bottom ?? null, top: top ?? null },
869
+ visibility,
870
+ behaviors: [],
871
+ _key: `!scenes.regions!${ctx.sceneId}.${id}`,
872
+ };
873
+
874
+ if (spec.restrict != null) {
875
+ if (!RESTRICTION_TYPES.includes(spec.restrict)) {
876
+ throw new Error(
877
+ `${label}: unknown restrict "${spec.restrict}" — expected one of ` +
878
+ `${RESTRICTION_TYPES.join(", ")}`,
879
+ );
880
+ }
881
+ doc.restriction = { enabled: true, type: spec.restrict, priority: 0 };
882
+ // A restricted region must belong to exactly one level, or the shape
883
+ // constraint silently never computes. `levels` is otherwise never
884
+ // emitted: the empty set means "all levels", which is right for the
885
+ // one-scene-per-floor rule.
886
+ doc.levels = [DEFAULT_LEVEL_ID];
887
+ }
888
+
889
+ const behaviorCtx = { ...ctx, regionId: id, regionLabel: label };
890
+ for (const [bKey, bSpec] of Object.entries(spec.behaviors ?? {})) {
891
+ doc.behaviors.push(buildBehavior(bKey, bSpec, behaviorCtx));
892
+ }
893
+ return doc;
894
+ }
895
+
896
+ /* -------------------------------------------------------------------- */
897
+ /* The scene */
898
+ /* -------------------------------------------------------------------- */
899
+
900
+ /**
901
+ * Compile a map note into a Scene document, embedded documents and all.
902
+ *
903
+ * Every embedded document carries its own `_key`. This is not decoration: the
904
+ * compendium CLI writes each one straight to its sublevel by that key, and a
905
+ * missing one fails the compile with `Key cannot be null or undefined`.
906
+ *
907
+ * @param {object} fm - The note's frontmatter.
908
+ * @param {object} ctx - The compile context:
909
+ * `packageId`, `journalEntryId`, `journalPack` (the pack the note's derived
910
+ * JournalEntry landed in), `pageIds` (heading key → page id),
911
+ * `resolveRegionRef` / `resolveBehaviorRef` / `resolveEffectRef` (address →
912
+ * UUID), `knownActions`, `warnings`, and optionally `folder` and `stats`.
913
+ * @returns {object} The Scene document, keyed for the pack.
914
+ * @throws {Error} On any authoring mistake Foundry would accept silently.
915
+ */
916
+ export function buildScene(fm, ctx) {
917
+ const sohl = fm.sohl ?? {};
918
+ const sceneId = fm.id;
919
+ if (!sceneId) throw new Error("a map note needs an `id`");
920
+ const profile = mapProfile(fm.type);
921
+
922
+ const dimensions = sohl.dimensions;
923
+ if (
924
+ !Array.isArray(dimensions) ||
925
+ dimensions.length !== 2 ||
926
+ !dimensions.every((n) => Number.isInteger(n) && n > 0)
927
+ ) {
928
+ throw new Error(
929
+ "`dimensions` is [width, height] in whole pixels — the map's own size",
930
+ );
931
+ }
932
+ const pxPerGrid = sohl.pxPerGrid;
933
+ if (!Number.isInteger(pxPerGrid) || pxPerGrid <= 0) {
934
+ throw new Error(
935
+ "`pxPerGrid` is the whole number of pixels per grid square, and must " +
936
+ "match the art",
937
+ );
938
+ }
939
+ if (!sohl.image) throw new Error("a map note needs an `image`");
940
+
941
+ const warn = (message) => {
942
+ if (ctx.warnings) ctx.warnings.push(message);
943
+ };
944
+ const inner = { ...ctx, sceneId, warn };
945
+ const geom = { label: "", pxPerGrid, dimensions };
946
+
947
+ const scene = {
948
+ name: ctx.name ?? fm.name?.full ?? "Unnamed Map",
949
+ _id: sceneId,
950
+ navigation: true,
951
+ navOrder: 0,
952
+ width: dimensions[0],
953
+ height: dimensions[1],
954
+ padding: profile.padding,
955
+ grid: {
956
+ type: profile.grid.type,
957
+ size: pxPerGrid,
958
+ distance: profile.grid.distance,
959
+ units: profile.grid.units,
960
+ },
961
+ tokenVision: profile.tokenVision,
962
+ fog: { mode: profile.fog.mode },
963
+ initialLevel: DEFAULT_LEVEL_ID,
964
+ levels: [buildLevel(sohl, sceneId)],
965
+ drawings: [],
966
+ tokens: [],
967
+ lights: buildLights(sohl, geom, inner),
968
+ notes: buildLocations(sohl, geom, inner),
969
+ sounds: buildSounds(sohl, geom, inner),
970
+ tiles: buildTiles(sohl, geom, inner),
971
+ walls: buildWalls(sohl, geom, inner),
972
+ regions: buildRegions(sohl, geom, inner),
973
+ folder: ctx.folder ?? null,
974
+ sort: 0,
975
+ ownership: { default: 0 },
976
+ flags: {},
977
+ _key: `!scenes!${sceneId}`,
978
+ };
979
+ if (sohl.navName) scene.navName = sohl.navName;
980
+ if (ctx.stats) scene._stats = ctx.stats;
981
+
982
+ if (ctx.journalEntryId) {
983
+ if (!ctx.packageId) {
984
+ throw new Error(
985
+ "a map note with a journal needs `packageId` in its compile context",
986
+ );
987
+ }
988
+ // `Scene.journal` is a plain ForeignDocumentField, which
989
+ // `ForeignDocumentField#initialize` unconditionally nulls inside a
990
+ // compendium. The source id survives (and resolves after an Adventure
991
+ // import, which reads source data and keeps ids), but a bare `scenes`
992
+ // pack needs the flag to find the entry at all.
993
+ scene.journal = ctx.journalEntryId;
994
+ scene.flags[ctx.packageId] = {
995
+ docUuid: compendiumUuid(
996
+ ctx.packageId,
997
+ "doc",
998
+ ctx.journalEntryId,
999
+ ctx.journalPack,
1000
+ ),
1001
+ };
1002
+ }
1003
+ return scene;
1004
+ }
1005
+
1006
+ /**
1007
+ * Synthesise the scene's single embedded Level from `image:` / `overlay:`.
1008
+ *
1009
+ * Authors never write `levels:`. A scene must ship at least one Level — the
1010
+ * client-side `_preCreate` net that would create one does not run for offline
1011
+ * pack compilation, and the server-side migration shim is version-gated on
1012
+ * `_stats.coreVersion`, so a pack stamped 14.x+ skips it and ships a scene with
1013
+ * no Level and no map.
1014
+ *
1015
+ * @param {object} sohl - The note's `sohl:` block.
1016
+ * @param {string} sceneId - The owning scene's `_id`.
1017
+ * @returns {object} The Level document, keyed for the pack.
1018
+ */
1019
+ export function buildLevel(sohl, sceneId) {
1020
+ const level = {
1021
+ _id: DEFAULT_LEVEL_ID,
1022
+ name: sohl.levelName ?? "Ground",
1023
+ elevation: { bottom: 0, top: 20 },
1024
+ background: {
1025
+ color: sohl.backgroundColor ?? "#999999",
1026
+ src: sohl.image,
1027
+ },
1028
+ foreground: { src: sohl.overlay ?? null },
1029
+ sort: 0,
1030
+ _key: `!scenes.levels!${sceneId}.${DEFAULT_LEVEL_ID}`,
1031
+ };
1032
+ return level;
1033
+ }
1034
+
1035
+ /**
1036
+ * Compile the `walls:` and `doors:` blocks into Wall documents.
1037
+ *
1038
+ * Walls are keyed by **feature** rather than by restriction signature, so a
1039
+ * diff reads "the hayloft walls changed" instead of "line 143 changed", and a
1040
+ * human can find the one wall they meant to move.
1041
+ *
1042
+ * @param {object} sohl - The note's `sohl:` block.
1043
+ * @param {MapGeometry} geom - The map's measurements.
1044
+ * @param {object} ctx - The compile context.
1045
+ * @returns {object[]} The Wall documents.
1046
+ */
1047
+ export function buildWalls(sohl, geom, ctx) {
1048
+ const out = [];
1049
+ for (const [key, spec] of Object.entries(sohl.walls ?? {})) {
1050
+ const label = `walls.${key}`;
1051
+ const segments = toList(spec.segments);
1052
+ if (!segments.length) {
1053
+ throw new Error(
1054
+ `${label}: a wall group needs at least one segment`,
1055
+ );
1056
+ }
1057
+ segments.forEach((segment, i) => {
1058
+ const id = makeId("scene-wall", `${ctx.sceneId}:${key}:${i}`);
1059
+ out.push(
1060
+ buildWall(
1061
+ segment,
1062
+ spec,
1063
+ { ...geom, label },
1064
+ {
1065
+ sceneId: ctx.sceneId,
1066
+ id,
1067
+ },
1068
+ ),
1069
+ );
1070
+ });
1071
+ }
1072
+ for (const [key, spec] of Object.entries(sohl.doors ?? {})) {
1073
+ const label = `doors.${key}`;
1074
+ const id = spec._id || makeId("scene-door", `${ctx.sceneId}:${key}`);
1075
+ out.push(
1076
+ buildWall(
1077
+ spec.segment,
1078
+ { kind: "door", ...spec },
1079
+ { ...geom, label },
1080
+ {
1081
+ sceneId: ctx.sceneId,
1082
+ id,
1083
+ },
1084
+ ),
1085
+ );
1086
+ }
1087
+ return out;
1088
+ }
1089
+
1090
+ /**
1091
+ * Compile the `lights:` block into AmbientLight documents.
1092
+ *
1093
+ * `dim` and `bright` are radii in the scene's **distance units** (Foundry's own
1094
+ * meaning), not pixels; only `position:` is pixels.
1095
+ *
1096
+ * @param {object} sohl - The note's `sohl:` block.
1097
+ * @param {MapGeometry} geom - The map's measurements.
1098
+ * @param {object} ctx - The compile context.
1099
+ * @returns {object[]} The AmbientLight documents.
1100
+ */
1101
+ export function buildLights(sohl, geom, ctx) {
1102
+ return Object.entries(sohl.lights ?? {}).map(([key, spec]) => {
1103
+ const label = `lights.${key}`;
1104
+ const [x, y] = requirePosition(spec.position, { ...geom, label });
1105
+ const id = spec._id || makeId("scene-light", `${ctx.sceneId}:${key}`);
1106
+ const config = {
1107
+ dim: spec.dim ?? 0,
1108
+ bright: spec.bright ?? 0,
1109
+ angle: spec.angle ?? 360,
1110
+ };
1111
+ if (spec.color) config.color = spec.color;
1112
+ if (spec.animation) config.animation = spec.animation;
1113
+ return {
1114
+ _id: id,
1115
+ x,
1116
+ y,
1117
+ rotation: spec.rotation ?? 0,
1118
+ walls: spec.walls ?? true,
1119
+ vision: spec.vision ?? false,
1120
+ config,
1121
+ _key: `!scenes.lights!${ctx.sceneId}.${id}`,
1122
+ };
1123
+ });
1124
+ }
1125
+
1126
+ /**
1127
+ * Compile the `tiles:` block into Tile documents.
1128
+ *
1129
+ * @param {object} sohl - The note's `sohl:` block.
1130
+ * @param {MapGeometry} geom - The map's measurements.
1131
+ * @param {object} ctx - The compile context.
1132
+ * @returns {object[]} The Tile documents.
1133
+ */
1134
+ export function buildTiles(sohl, geom, ctx) {
1135
+ return Object.entries(sohl.tiles ?? {}).map(([key, spec]) => {
1136
+ const label = `tiles.${key}`;
1137
+ const [x, y] = requirePosition(spec.position, { ...geom, label });
1138
+ const size = toList(spec.size);
1139
+ expectLength(size, 2, `${label}: size is [width, height] in pixels`);
1140
+ assertPixelGeometry(size, { ...geom, label });
1141
+ if (!spec.image) throw new Error(`${label}: a tile needs an image`);
1142
+ const id = spec._id || makeId("scene-tile", `${ctx.sceneId}:${key}`);
1143
+ return {
1144
+ _id: id,
1145
+ x,
1146
+ y,
1147
+ width: size[0],
1148
+ height: size[1],
1149
+ elevation: spec.elevation ?? 0,
1150
+ rotation: spec.rotation ?? 0,
1151
+ alpha: spec.alpha ?? 1,
1152
+ sort: 0,
1153
+ texture: { src: spec.image },
1154
+ _key: `!scenes.tiles!${ctx.sceneId}.${id}`,
1155
+ };
1156
+ });
1157
+ }
1158
+
1159
+ /**
1160
+ * Compile the `sounds:` block into AmbientSound documents.
1161
+ *
1162
+ * Positional audio is the one audio mechanism that packages cleanly: `path` is
1163
+ * a plain `FilePathField` with no document reference behind it.
1164
+ *
1165
+ * @param {object} sohl - The note's `sohl:` block.
1166
+ * @param {MapGeometry} geom - The map's measurements.
1167
+ * @param {object} ctx - The compile context.
1168
+ * @returns {object[]} The AmbientSound documents.
1169
+ */
1170
+ export function buildSounds(sohl, geom, ctx) {
1171
+ return Object.entries(sohl.sounds ?? {}).map(([key, spec]) => {
1172
+ const label = `sounds.${key}`;
1173
+ const [x, y] = requirePosition(spec.position, { ...geom, label });
1174
+ if (!spec.path)
1175
+ throw new Error(`${label}: an ambient sound needs a path`);
1176
+ const id = spec._id || makeId("scene-sound", `${ctx.sceneId}:${key}`);
1177
+ return {
1178
+ _id: id,
1179
+ name: spec.name ?? key,
1180
+ x,
1181
+ y,
1182
+ radius: spec.radius ?? 0,
1183
+ path: spec.path,
1184
+ repeat: spec.repeat ?? true,
1185
+ volume: spec.volume ?? 0.5,
1186
+ walls: spec.walls ?? true,
1187
+ easing: spec.easing ?? true,
1188
+ _key: `!scenes.sounds!${ctx.sceneId}.${id}`,
1189
+ };
1190
+ });
1191
+ }
1192
+
1193
+ /**
1194
+ * Compile the `locations:` block into Note documents — the map pins.
1195
+ *
1196
+ * A location's key names a heading in the note's body, and the pin points at
1197
+ * the journal page that heading compiled into. `Note.entryId` / `pageId` are
1198
+ * `{idOnly: true}` fields, which return before the compendium guard, so pin
1199
+ * targets survive packing intact — but `Note#entry` resolves by bare id against
1200
+ * the **world** collection, which is why a map with pins ships inside an
1201
+ * Adventure (`keepId: true`).
1202
+ *
1203
+ * @param {object} sohl - The note's `sohl:` block.
1204
+ * @param {MapGeometry} geom - The map's measurements.
1205
+ * @param {object} ctx - The compile context.
1206
+ * @returns {object[]} The Note documents.
1207
+ */
1208
+ export function buildLocations(sohl, geom, ctx) {
1209
+ const locations = Object.entries(sohl.locations ?? {});
1210
+ if (!locations.length) return [];
1211
+ if (!ctx.journalEntryId) {
1212
+ throw new Error(
1213
+ "`locations:` need a journal to point at — give the note a body with " +
1214
+ "a heading per location",
1215
+ );
1216
+ }
1217
+ return locations.map(([key, spec]) => {
1218
+ const label = `locations.${key}`;
1219
+ const at = toList(spec.at);
1220
+ expectLength(at, 2, `${label}: at is [x, y] in grid squares`);
1221
+ if (!at.every((n) => Number.isFinite(n))) {
1222
+ throw new Error(`${label}: at is [x, y] in grid squares`);
1223
+ }
1224
+ assertGridLocation(at, { ...geom, label });
1225
+ const pageId = ctx.pageIds.get(key);
1226
+ if (!pageId) {
1227
+ throw new Error(
1228
+ `${label}: no body heading matches "${key}" — a location pin points ` +
1229
+ `at the page its heading compiled into. Headings available: ` +
1230
+ `${[...ctx.pageIds.keys()].join(", ") || "(none)"}`,
1231
+ );
1232
+ }
1233
+ const id = spec._id || makeId("scene-note", `${ctx.sceneId}:${key}`);
1234
+ return {
1235
+ _id: id,
1236
+ entryId: ctx.journalEntryId,
1237
+ pageId,
1238
+ x: Math.round(at[0] * geom.pxPerGrid),
1239
+ y: Math.round(at[1] * geom.pxPerGrid),
1240
+ text: spec.label ?? "",
1241
+ iconSize: spec.iconSize ?? 40,
1242
+ _key: `!scenes.notes!${ctx.sceneId}.${id}`,
1243
+ };
1244
+ });
1245
+ }
1246
+
1247
+ /**
1248
+ * Compile the `regions:` block into Region documents with their behaviours.
1249
+ *
1250
+ * @param {object} sohl - The note's `sohl:` block.
1251
+ * @param {MapGeometry} geom - The map's measurements.
1252
+ * @param {object} ctx - The compile context.
1253
+ * @returns {object[]} The Region documents.
1254
+ */
1255
+ export function buildRegions(sohl, geom, ctx) {
1256
+ return Object.entries(sohl.regions ?? {}).map(([key, spec]) =>
1257
+ buildRegion(key, spec, geom, ctx),
1258
+ );
1259
+ }
1260
+
1261
+ /* -------------------------------------------------------------------- */
1262
+ /* Small shared helpers */
1263
+ /* -------------------------------------------------------------------- */
1264
+
1265
+ /**
1266
+ * A value as a list: absent → empty, scalar → single, list → itself.
1267
+ *
1268
+ * @param {*} value - The authored value.
1269
+ * @returns {Array} The list form.
1270
+ */
1271
+ function toList(value) {
1272
+ if (value == null) return [];
1273
+ return Array.isArray(value) ? value : [value];
1274
+ }
1275
+
1276
+ /**
1277
+ * Assert a list's length, with the authored form named in the message.
1278
+ *
1279
+ * @param {Array} list - The list.
1280
+ * @param {number} length - The expected length.
1281
+ * @param {string} message - The error to throw.
1282
+ */
1283
+ function expectLength(list, length, message) {
1284
+ if (list.length !== length) throw new Error(message);
1285
+ }
1286
+
1287
+ /**
1288
+ * Read a `position:` (pixels) and lint its units.
1289
+ *
1290
+ * @param {*} position - The authored `[x, y]`.
1291
+ * @param {MapGeometry} geom - The map's measurements.
1292
+ * @returns {number[]} The rounded pixel coordinates.
1293
+ */
1294
+ function requirePosition(position, geom) {
1295
+ const at = toList(position);
1296
+ expectLength(at, 2, `${geom.label}: position is [x, y] in pixels`);
1297
+ if (!at.every((n) => Number.isFinite(n))) {
1298
+ throw new Error(`${geom.label}: position is [x, y] in pixels`);
1299
+ }
1300
+ assertPixelGeometry(at, geom);
1301
+ return at.map((n) => Math.round(n));
1302
+ }