@heroiclands/package-build 23.0.0 → 23.1.1

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