@heroiclands/package-build 22.14.4 → 22.15.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.
- package/CHANGELOG.md +34 -0
- package/CONTENT.md +25 -1
- package/bin/content-build.mjs +3 -7
- package/bin/package-build.mjs +25 -0
- package/docs/api.md +61 -34
- package/docs/commands.md +65 -9
- package/docs/configuration.md +6 -3
- package/docs/content-format.md +165 -121
- package/docs/diagnostics.md +1 -1
- package/docs/getting-started.md +23 -0
- package/engine/base-compiler.mjs +3 -1
- package/engine/being-age.mjs +16 -24
- package/engine/calendar-artifacts.mjs +153 -0
- package/engine/calendar-notes.mjs +64 -16
- package/engine/calendars.mjs +129 -0
- package/engine/content-index.mjs +3 -1
- package/engine/exported-scene.mjs +230 -0
- package/engine/generate.mjs +1 -0
- package/engine/ids.mjs +1 -1
- package/engine/index.mjs +6 -0
- package/engine/infobox.mjs +4 -0
- package/engine/itinerary-scenes.mjs +172 -0
- package/engine/map-build.mjs +10 -45
- package/engine/map-dot.mjs +6 -3
- package/engine/map-graphviz.mjs +50 -131
- package/engine/map-notes.mjs +26 -3
- package/engine/map-places.mjs +12 -6
- package/engine/map-raster.mjs +51 -0
- package/engine/note-dates.mjs +125 -85
- package/engine/note-vocabulary.mjs +22 -6
- package/engine/pdf-build.mjs +57 -1
- package/engine/pdf-render.mjs +18 -0
- package/engine/reckoning-markers.mjs +207 -0
- package/engine/scenes.mjs +113 -26
- package/engine/site-build.mjs +11 -13
- package/engine/site-maps.mjs +5 -35
- package/engine/site-preview.mjs +6 -1
- package/engine/social-tie-terms.mjs +3 -0
- package/engine/social-ties.mjs +30 -19
- package/package.json +3 -1
- package/types/engine/being-age.d.mts +2 -2
- package/types/engine/calendar-artifacts.d.mts +16 -0
- package/types/engine/calendar-notes.d.mts +1 -1
- package/types/engine/calendars.d.mts +65 -0
- package/types/engine/exported-scene.d.mts +12 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/itinerary-scenes.d.mts +29 -0
- package/types/engine/map-build.d.mts +4 -13
- package/types/engine/map-graphviz.d.mts +17 -59
- package/types/engine/map-places.d.mts +5 -1
- package/types/engine/map-raster.d.mts +8 -0
- package/types/engine/note-dates.d.mts +6 -0
- package/types/engine/note-vocabulary.d.mts +3 -3
- package/types/engine/pdf-render.d.mts +7 -1
- package/types/engine/reckoning-markers.d.mts +27 -0
- package/types/engine/scenes.d.mts +9 -2
- package/types/engine/site-build.d.mts +1 -5
- package/types/engine/site-maps.d.mts +2 -7
- package/types/engine/social-tie-terms.d.mts +2 -0
- package/types/engine/social-ties.d.mts +2 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Preserve an exported Foundry Scene while binding marked pins to note pages. */
|
|
6
|
+
|
|
7
|
+
import { journalPageId, splitPages } from "./journals.mjs";
|
|
8
|
+
|
|
9
|
+
const EMBEDDED = Object.freeze([
|
|
10
|
+
"drawings",
|
|
11
|
+
"tokens",
|
|
12
|
+
"lights",
|
|
13
|
+
"notes",
|
|
14
|
+
"sounds",
|
|
15
|
+
"tiles",
|
|
16
|
+
"walls",
|
|
17
|
+
"regions",
|
|
18
|
+
"levels",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** Parse the deliberately small path syntax used inside a Scene fixup. */
|
|
22
|
+
function fixupSegments(path) {
|
|
23
|
+
if (typeof path !== "string" || !path.startsWith(".")) {
|
|
24
|
+
throw new Error("a Scene fixup path must start with `.`");
|
|
25
|
+
}
|
|
26
|
+
const segments = [];
|
|
27
|
+
let at = 0;
|
|
28
|
+
while (at < path.length) {
|
|
29
|
+
const rest = path.slice(at);
|
|
30
|
+
const property = /^\.([A-Za-z_$][A-Za-z0-9_$]*)/.exec(rest);
|
|
31
|
+
if (property) {
|
|
32
|
+
segments.push({ kind: "property", key: property[1] });
|
|
33
|
+
at += property[0].length;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const bareId = /^\[([A-Za-z0-9]{16})\]/.exec(rest);
|
|
37
|
+
if (bareId) {
|
|
38
|
+
segments.push({ kind: "id", key: bareId[1] });
|
|
39
|
+
at += bareId[0].length;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const byId = /^\[_id=(?:"([A-Za-z0-9]{16})"|'([A-Za-z0-9]{16})'|([A-Za-z0-9]{16}))\]/.exec(
|
|
43
|
+
rest,
|
|
44
|
+
);
|
|
45
|
+
if (byId) {
|
|
46
|
+
segments.push({ kind: "id", key: byId[1] ?? byId[2] ?? byId[3] });
|
|
47
|
+
at += byId[0].length;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const index = /^\[(\d+)\]/.exec(rest);
|
|
51
|
+
if (index) {
|
|
52
|
+
segments.push({ kind: "index", key: Number(index[1]) });
|
|
53
|
+
at += index[0].length;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`invalid Scene fixup path "${path}" at "${rest}"`);
|
|
57
|
+
}
|
|
58
|
+
return segments;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Find the existing field a fixup addresses, without creating paths. */
|
|
62
|
+
function fixupTarget(scene, path) {
|
|
63
|
+
const segments = fixupSegments(path);
|
|
64
|
+
let current = scene;
|
|
65
|
+
for (const segment of segments.slice(0, -1)) {
|
|
66
|
+
if (segment.kind === "property") {
|
|
67
|
+
if (!current || typeof current !== "object" || !Object.hasOwn(current, segment.key)) {
|
|
68
|
+
throw new Error(`Scene fixup path "${path}" has no property "${segment.key}"`);
|
|
69
|
+
}
|
|
70
|
+
current = current[segment.key];
|
|
71
|
+
} else if (segment.kind === "index") {
|
|
72
|
+
if (!Array.isArray(current) || segment.key >= current.length) {
|
|
73
|
+
throw new Error(`Scene fixup path "${path}" has no array index ${segment.key}`);
|
|
74
|
+
}
|
|
75
|
+
current = current[segment.key];
|
|
76
|
+
} else {
|
|
77
|
+
if (!Array.isArray(current)) {
|
|
78
|
+
throw new Error(`Scene fixup path "${path}" needs an array for _id selection`);
|
|
79
|
+
}
|
|
80
|
+
const matches = current.filter((value) => value?._id === segment.key);
|
|
81
|
+
if (matches.length !== 1) {
|
|
82
|
+
throw new Error(`Scene fixup path "${path}" matches ${matches.length} documents`);
|
|
83
|
+
}
|
|
84
|
+
current = matches[0];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const last = segments.at(-1);
|
|
88
|
+
if (
|
|
89
|
+
last?.kind !== "property" ||
|
|
90
|
+
!current ||
|
|
91
|
+
typeof current !== "object" ||
|
|
92
|
+
!Object.hasOwn(current, last.key)
|
|
93
|
+
) {
|
|
94
|
+
throw new Error(`Scene fixup path "${path}" must end at an existing property`);
|
|
95
|
+
}
|
|
96
|
+
return { parent: current, key: last.key };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Apply author-declared asset address replacements to an exported Scene. */
|
|
100
|
+
function applyFixups(scene, fixups, resolveAddress) {
|
|
101
|
+
if (fixups === undefined) return;
|
|
102
|
+
if (!Array.isArray(fixups)) throw new Error("`data.fixup` must be an array");
|
|
103
|
+
for (const [index, fixup] of fixups.entries()) {
|
|
104
|
+
if (
|
|
105
|
+
!fixup ||
|
|
106
|
+
typeof fixup !== "object" ||
|
|
107
|
+
Array.isArray(fixup) ||
|
|
108
|
+
fixup.type !== "address" ||
|
|
109
|
+
typeof fixup.value !== "string" ||
|
|
110
|
+
!fixup.value
|
|
111
|
+
) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`data.fixup[${index}] needs path, type: address, and an asset address value`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const { parent, key } = fixupTarget(scene, fixup.path);
|
|
117
|
+
if (typeof parent[key] !== "string" && parent[key] !== null) {
|
|
118
|
+
throw new Error(`Scene fixup path "${fixup.path}" must name a string asset field`);
|
|
119
|
+
}
|
|
120
|
+
if (typeof resolveAddress !== "function") {
|
|
121
|
+
throw new Error("Scene address fixups need an asset resolver");
|
|
122
|
+
}
|
|
123
|
+
const resolved = resolveAddress(fixup.value);
|
|
124
|
+
if (typeof resolved !== "string" || !resolved) {
|
|
125
|
+
throw new Error(`Scene fixup address "${fixup.value}" does not resolve to an asset`);
|
|
126
|
+
}
|
|
127
|
+
parent[key] = resolved;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Add a compendium key to one exported embedded document. */
|
|
132
|
+
function keyDocument(doc, collection, parentIds, sceneId) {
|
|
133
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
134
|
+
throw new Error(`data.scene.${collection} contains a non-document value`);
|
|
135
|
+
}
|
|
136
|
+
if (!/^[A-Za-z0-9]{16}$/.test(doc._id ?? "")) {
|
|
137
|
+
throw new Error(`data.scene.${collection} needs a 16-character _id on every document`);
|
|
138
|
+
}
|
|
139
|
+
doc._key = `!scenes.${collection}!${[sceneId, ...parentIds, doc._id].join(".")}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compile a Foundry Scene export into a pack document.
|
|
144
|
+
*
|
|
145
|
+
* Only the note-derived Scene ID, compendium metadata, and pins marked
|
|
146
|
+
* `#anchor` change. Every other authored Scene field passes through.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} fm - The map note's frontmatter.
|
|
149
|
+
* @param {string} markdown - Its converted Markdown body.
|
|
150
|
+
* @param {object} [opts] - Pack metadata, derived JournalEntry ID, and asset resolver.
|
|
151
|
+
* @returns {object} The keyed Scene document.
|
|
152
|
+
*/
|
|
153
|
+
export function buildExportedScene(fm, markdown, { journalEntryId, stats, resolveAddress } = {}) {
|
|
154
|
+
if (!["battlemap", "localmap"].includes(fm.subType)) {
|
|
155
|
+
throw new Error("`data.scene` is for battlemap and localmap notes");
|
|
156
|
+
}
|
|
157
|
+
const source = fm.data?.scene;
|
|
158
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
|
159
|
+
throw new Error("a map note needs a Foundry Scene object at `data.scene`");
|
|
160
|
+
}
|
|
161
|
+
if (!/^[A-Za-z0-9]{16}$/.test(fm.id ?? "")) {
|
|
162
|
+
throw new Error("a map note needs a derived 16-character Scene ID");
|
|
163
|
+
}
|
|
164
|
+
const scene = structuredClone(source);
|
|
165
|
+
applyFixups(scene, fm.data?.fixup, resolveAddress);
|
|
166
|
+
if (
|
|
167
|
+
!(Number.isInteger(scene.width) && scene.width > 0) ||
|
|
168
|
+
!(Number.isInteger(scene.height) && scene.height > 0)
|
|
169
|
+
) {
|
|
170
|
+
throw new Error("`data.scene` needs positive whole-number width and height");
|
|
171
|
+
}
|
|
172
|
+
if (!Array.isArray(scene.levels) || scene.levels.length === 0) {
|
|
173
|
+
throw new Error("`data.scene.levels` needs at least one Level");
|
|
174
|
+
}
|
|
175
|
+
scene._id = fm.id;
|
|
176
|
+
scene._key = `!scenes!${fm.id}`;
|
|
177
|
+
if (stats) scene._stats = stats;
|
|
178
|
+
|
|
179
|
+
for (const collection of EMBEDDED) {
|
|
180
|
+
const docs = scene[collection];
|
|
181
|
+
if (docs === undefined) continue;
|
|
182
|
+
if (!Array.isArray(docs)) {
|
|
183
|
+
throw new Error(`data.scene.${collection} must be an array`);
|
|
184
|
+
}
|
|
185
|
+
const ids = new Set();
|
|
186
|
+
for (const doc of docs) {
|
|
187
|
+
keyDocument(doc, collection, [], fm.id);
|
|
188
|
+
if (ids.has(doc._id))
|
|
189
|
+
throw new Error(`data.scene.${collection} repeats _id ${doc._id}`);
|
|
190
|
+
ids.add(doc._id);
|
|
191
|
+
if (collection !== "regions" || doc.behaviors === undefined) continue;
|
|
192
|
+
if (!Array.isArray(doc.behaviors)) {
|
|
193
|
+
throw new Error("data.scene.regions.behaviors must be an array");
|
|
194
|
+
}
|
|
195
|
+
const behaviorIds = new Set();
|
|
196
|
+
for (const behavior of doc.behaviors) {
|
|
197
|
+
keyDocument(behavior, "regions.behaviors", [doc._id], fm.id);
|
|
198
|
+
if (behaviorIds.has(behavior._id)) {
|
|
199
|
+
throw new Error(`data.scene.regions.behaviors repeats _id ${behavior._id}`);
|
|
200
|
+
}
|
|
201
|
+
behaviorIds.add(behavior._id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (scene.initialLevel && !scene.levels.some((level) => level._id === scene.initialLevel)) {
|
|
206
|
+
throw new Error(`data.scene.initialLevel "${scene.initialLevel}" names no Level`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const pages = new Map();
|
|
210
|
+
for (const page of splitPages(markdown, fm.name?.full ?? scene.name)) {
|
|
211
|
+
if (!page.anchorSlug) continue;
|
|
212
|
+
if (pages.has(page.anchorSlug)) {
|
|
213
|
+
throw new Error(`the map note repeats anchor "${page.anchorSlug}"`);
|
|
214
|
+
}
|
|
215
|
+
pages.set(page.anchorSlug, page);
|
|
216
|
+
}
|
|
217
|
+
for (const pin of scene.notes ?? []) {
|
|
218
|
+
const match = /^#([A-Za-z0-9][A-Za-z0-9_-]*)$/.exec(String(pin.text ?? ""));
|
|
219
|
+
if (!match) continue;
|
|
220
|
+
const page = pages.get(match[1]);
|
|
221
|
+
if (!page) throw new Error(`Scene pin "${pin.text}" names no heading anchor in this note`);
|
|
222
|
+
if (!journalEntryId) {
|
|
223
|
+
throw new Error(`Scene pin "${pin.text}" needs a JournalEntry from this note`);
|
|
224
|
+
}
|
|
225
|
+
pin.entryId = journalEntryId;
|
|
226
|
+
pin.pageId = journalPageId(journalEntryId, page);
|
|
227
|
+
pin.text = page.name;
|
|
228
|
+
}
|
|
229
|
+
return scene;
|
|
230
|
+
}
|
package/engine/generate.mjs
CHANGED
|
@@ -584,6 +584,7 @@ async function generatePack(
|
|
|
584
584
|
// for the same reason `itemsSourceDirs` is, and scoped to this
|
|
585
585
|
// pack's system so a bundle holds the catalogue it is compiled for.
|
|
586
586
|
bundleSourceDirs: bundleSourceJsonDirs(config, system ?? null),
|
|
587
|
+
config,
|
|
587
588
|
folderResolver: resolver,
|
|
588
589
|
// One answer to "which files are the corpus?", from the configuration
|
|
589
590
|
// this build resolved rather than from the working directory.
|
package/engine/ids.mjs
CHANGED
|
@@ -62,7 +62,7 @@ export const MAP_TYPES = Object.freeze(new Set(["map"]));
|
|
|
62
62
|
*
|
|
63
63
|
* @type {readonly string[]}
|
|
64
64
|
*/
|
|
65
|
-
export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap"]);
|
|
65
|
+
export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap", "totm"]);
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Content types whose whole document **is** a JournalEntry.
|
package/engine/index.mjs
CHANGED
|
@@ -130,9 +130,15 @@ export * as calendars from "./calendars.mjs";
|
|
|
130
130
|
/** A note's dates: one authored string, one grammar, one record shape. */
|
|
131
131
|
export * as noteDates from "./note-dates.mjs";
|
|
132
132
|
|
|
133
|
+
/** Era markers resolved from calendar notes in a corpus. */
|
|
134
|
+
export * as reckoningMarkers from "./reckoning-markers.mjs";
|
|
135
|
+
|
|
133
136
|
/** Calendars as notes: what one may declare, and the definition emitted from it. */
|
|
134
137
|
export * as calendarNotes from "./calendar-notes.mjs";
|
|
135
138
|
|
|
139
|
+
/** Compiled calendar definitions and import files from the content index. */
|
|
140
|
+
export * as calendarArtifacts from "./calendar-artifacts.mjs";
|
|
141
|
+
|
|
136
142
|
/** A being's `age`, absent beside a dated `born`: the age the date computes to. */
|
|
137
143
|
export * as beingAge from "./being-age.mjs";
|
|
138
144
|
|
package/engine/infobox.mjs
CHANGED
|
@@ -575,6 +575,7 @@ function structuredRows(field, raw, resolve, label, fm) {
|
|
|
575
575
|
}
|
|
576
576
|
if (isMapping(raw) && !isAddressTuple(raw)) {
|
|
577
577
|
const groups = new Map();
|
|
578
|
+
const seen = new Set();
|
|
578
579
|
for (const [target, relation] of Object.entries(raw)) {
|
|
579
580
|
if (!hasValue(target) || !hasValue(relation) || isMapping(relation)) continue;
|
|
580
581
|
const term = String(relation);
|
|
@@ -591,6 +592,9 @@ function structuredRows(field, raw, resolve, label, fm) {
|
|
|
591
592
|
);
|
|
592
593
|
if (address.reason || (field.accepts && !field.accepts.includes(address.type)))
|
|
593
594
|
continue;
|
|
595
|
+
const canonical = renderAddress(address);
|
|
596
|
+
if (seen.has(canonical)) continue;
|
|
597
|
+
seen.add(canonical);
|
|
594
598
|
}
|
|
595
599
|
const links = groups.get(term) ?? [];
|
|
596
600
|
links.push(linkValue(target, resolve, { type: field.ref }));
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Gridless Foundry Scenes drawn from place relations. */
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { resolvePackageBuildConfig } from "../config.mjs";
|
|
10
|
+
|
|
11
|
+
import { buildMaps, relatedPlaces } from "./map-build.mjs";
|
|
12
|
+
import { renderDot } from "./map-graphviz.mjs";
|
|
13
|
+
import { mapWorld } from "./map-places.mjs";
|
|
14
|
+
import { loadForeignIndexes } from "./metadata-index.mjs";
|
|
15
|
+
import { authoredFrontmatter, isNoteRecord } from "./index-records.mjs";
|
|
16
|
+
import { buildScene } from "./map-notes.mjs";
|
|
17
|
+
import { buildJournalEntry } from "./journals.mjs";
|
|
18
|
+
import { makeId } from "./ids.mjs";
|
|
19
|
+
import { stripAdventureKeys } from "./bundle-notes.mjs";
|
|
20
|
+
|
|
21
|
+
/** The PNG's dimensions, from its fixed header. */
|
|
22
|
+
function pngDimensions(file) {
|
|
23
|
+
const bytes = fs.readFileSync(file);
|
|
24
|
+
if (bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") {
|
|
25
|
+
throw new Error(`${file} is not a PNG`);
|
|
26
|
+
}
|
|
27
|
+
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** GraphViz's plain coordinates, converted to pixel positions on its PNG. */
|
|
31
|
+
export function pinPositions(plain, width, height) {
|
|
32
|
+
const graph = /^graph\s+\S+\s+(\S+)\s+(\S+)/m.exec(plain);
|
|
33
|
+
if (!graph) throw new Error("GraphViz emitted no graph size for itinerary pins");
|
|
34
|
+
const graphWidth = Number(graph[1]);
|
|
35
|
+
const graphHeight = Number(graph[2]);
|
|
36
|
+
if (!(graphWidth > 0 && graphHeight > 0)) {
|
|
37
|
+
throw new Error("GraphViz emitted an invalid graph size for itinerary pins");
|
|
38
|
+
}
|
|
39
|
+
const positions = new Map();
|
|
40
|
+
for (const line of plain.split("\n")) {
|
|
41
|
+
const match = /^node\s+(?:"(p_[^"]+)"|(p_\S+))\s+(\S+)\s+(\S+)/.exec(line);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
const shortcode = (match[1] ?? match[2]).slice(2);
|
|
44
|
+
positions.set(shortcode, {
|
|
45
|
+
x: Math.round((Number(match[3]) / graphWidth) * width),
|
|
46
|
+
y: Math.round((1 - Number(match[4]) / graphHeight) * height),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return positions;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The compiled local journals a generated Adventure can import with its Scenes. */
|
|
53
|
+
function localJournals(dirs) {
|
|
54
|
+
const byId = new Map();
|
|
55
|
+
for (const dir of dirs) {
|
|
56
|
+
if (!fs.existsSync(dir)) continue;
|
|
57
|
+
for (const file of fs.readdirSync(dir)) {
|
|
58
|
+
if (!file.endsWith(".json")) continue;
|
|
59
|
+
const doc = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
60
|
+
if (String(doc._key ?? "").startsWith("!journal!")) byId.set(doc._id, doc);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return byId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emit backgrounds into the staged package and return Scenes and the journals
|
|
68
|
+
* their pins need. The caller writes them into its Scene and Adventure packs.
|
|
69
|
+
*/
|
|
70
|
+
export function buildItineraryScenes({
|
|
71
|
+
config,
|
|
72
|
+
records,
|
|
73
|
+
contentBase,
|
|
74
|
+
journalSourceDirs = [],
|
|
75
|
+
stats,
|
|
76
|
+
foreign = loadForeignIndexes(config, [config.contentPackage]),
|
|
77
|
+
}) {
|
|
78
|
+
const buildConfig = resolvePackageBuildConfig(config);
|
|
79
|
+
const world = mapWorld({ records, foreignIndex: foreign.index, contentBase, config });
|
|
80
|
+
const centres = relatedPlaces(world.places).filter((sc) => world.places.get(sc)?.local);
|
|
81
|
+
const findings = foreign.stale.map((stale) => ({
|
|
82
|
+
file: contentBase,
|
|
83
|
+
severity: "warning",
|
|
84
|
+
message: `the ${stale.package} content index is unavailable, so itinerary maps omit its places: ${stale.reason}`,
|
|
85
|
+
}));
|
|
86
|
+
const assetDir = path.resolve(
|
|
87
|
+
config.rootDir,
|
|
88
|
+
buildConfig.stageDir,
|
|
89
|
+
"assets/maps/generated-itineraries",
|
|
90
|
+
);
|
|
91
|
+
fs.rmSync(assetDir, { recursive: true, force: true });
|
|
92
|
+
if (!centres.length) return { scenes: [], journal: [], findings };
|
|
93
|
+
|
|
94
|
+
const outDir = path.resolve(config.rootDir, "build/map/foundry");
|
|
95
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
96
|
+
const drawn = buildMaps({ world, outDir, from: centres });
|
|
97
|
+
findings.push(...drawn.findings);
|
|
98
|
+
fs.mkdirSync(assetDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const localRecords = new Map();
|
|
101
|
+
for (const record of records) {
|
|
102
|
+
if (!isNoteRecord(record) || record.type !== "place") continue;
|
|
103
|
+
localRecords.set(String(record.shortcode).toLowerCase(), authoredFrontmatter(record));
|
|
104
|
+
}
|
|
105
|
+
const compiledJournals = localJournals(journalSourceDirs);
|
|
106
|
+
const imported = new Map();
|
|
107
|
+
const scenes = [];
|
|
108
|
+
for (const centre of centres) {
|
|
109
|
+
const dot = path.join(outDir, `from-${centre}.dot`);
|
|
110
|
+
const png = path.join(assetDir, `from-${centre}.png`);
|
|
111
|
+
const plain = path.join(outDir, `from-${centre}.plain`);
|
|
112
|
+
renderDot(dot, png, { engine: "neato", format: "png", nop: 2, dpi: 150 });
|
|
113
|
+
renderDot(dot, plain, { engine: "neato", format: "plain", nop: 2 });
|
|
114
|
+
const [width, height] = pngDimensions(png);
|
|
115
|
+
const positions = pinPositions(fs.readFileSync(plain, "utf8"), width, height);
|
|
116
|
+
const sceneId = makeId("itinerary-scene", `${config.contentPackage}:${centre}`);
|
|
117
|
+
const src = `${buildConfig.packageKind}/${buildConfig.packageId}/assets/maps/generated-itineraries/from-${centre}.png`;
|
|
118
|
+
const scene = buildScene(
|
|
119
|
+
{
|
|
120
|
+
id: sceneId,
|
|
121
|
+
name: { full: `From ${world.places.get(centre).name}` },
|
|
122
|
+
subType: "regionalmap",
|
|
123
|
+
sohl: { dimensions: [width, height], pxPerGrid: 100 },
|
|
124
|
+
data: { bgImage: src },
|
|
125
|
+
},
|
|
126
|
+
{ art: () => src, stats },
|
|
127
|
+
);
|
|
128
|
+
scene.grid.distance = 1;
|
|
129
|
+
scene.grid.units = "";
|
|
130
|
+
for (const [shortcode, at] of positions) {
|
|
131
|
+
const place = world.places.get(shortcode);
|
|
132
|
+
if (!place) continue;
|
|
133
|
+
let journal;
|
|
134
|
+
if (place.local) {
|
|
135
|
+
journal = compiledJournals.get(localRecords.get(shortcode)?.id);
|
|
136
|
+
} else {
|
|
137
|
+
const ref = foreign.index.get(place.address);
|
|
138
|
+
if (ref?.uuid && /\.JournalEntry\./.test(ref.uuid)) {
|
|
139
|
+
const id = makeId("itinerary-foreign-journal", place.address);
|
|
140
|
+
journal = buildJournalEntry({
|
|
141
|
+
id,
|
|
142
|
+
name: place.name,
|
|
143
|
+
markdown: `@UUID[${ref.uuid}]{Open ${place.name}}`,
|
|
144
|
+
stats,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!journal) {
|
|
149
|
+
findings.push({
|
|
150
|
+
file: place.file ? path.join(contentBase, place.file) : contentBase,
|
|
151
|
+
severity: "warning",
|
|
152
|
+
message: `place "${shortcode}" has no addressable JournalEntry, so its itinerary label has no Foundry pin`,
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
imported.set(journal._id, stripAdventureKeys(journal));
|
|
157
|
+
const id = makeId("itinerary-note", `${sceneId}:${shortcode}`);
|
|
158
|
+
scene.notes.push({
|
|
159
|
+
_id: id,
|
|
160
|
+
entryId: journal._id,
|
|
161
|
+
pageId: null,
|
|
162
|
+
x: at.x,
|
|
163
|
+
y: at.y,
|
|
164
|
+
text: place.name,
|
|
165
|
+
iconSize: 32,
|
|
166
|
+
_key: `!scenes.notes!${sceneId}.${id}`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
scenes.push(scene);
|
|
170
|
+
}
|
|
171
|
+
return { scenes, journal: [...imported.values()], findings };
|
|
172
|
+
}
|
package/engine/map-build.mjs
CHANGED
|
@@ -29,10 +29,7 @@
|
|
|
29
29
|
* - **`travel`** — the whole route graph, `travel.svg`.
|
|
30
30
|
*
|
|
31
31
|
* Every rendering is written beside the `.dot` it was drawn from, and the
|
|
32
|
-
* `.dot` is written first
|
|
33
|
-
* an error naming what to install, or — where the caller asked it to — writes
|
|
34
|
-
* the `.dot` files, returns one warning, and skips the rendering, which is
|
|
35
|
-
* what a site build asking for maps does rather than fail.
|
|
32
|
+
* `.dot` is written first.
|
|
36
33
|
*
|
|
37
34
|
* A build never mutates its inputs: nothing here reads a note except to
|
|
38
35
|
* locate a finding, and everything lands under the output directory.
|
|
@@ -44,7 +41,7 @@ import fs from "node:fs";
|
|
|
44
41
|
import path from "node:path";
|
|
45
42
|
|
|
46
43
|
import { fromDot, travelDot, treeDot } from "./map-dot.mjs";
|
|
47
|
-
import {
|
|
44
|
+
import { renderDot } from "./map-graphviz.mjs";
|
|
48
45
|
import { CHART_HORIZON_DAYS, layoutChart, layoutFrom, travelGraph } from "./map-layout.mjs";
|
|
49
46
|
import { TRAVEL_DAYS } from "./place-relations.mjs";
|
|
50
47
|
import { analyzeContainment, containmentFindings } from "./map-places.mjs";
|
|
@@ -106,17 +103,11 @@ export function relatedPlaces(places) {
|
|
|
106
103
|
* @param {number} [opts.ranksep] - The tree's `ranksep`, inches.
|
|
107
104
|
* @param {number} [opts.scale=1] - Multiplies every node's size and font.
|
|
108
105
|
* @param {boolean} [opts.polities=true] - Whether the tree draws polities.
|
|
109
|
-
* @param {(engine: string) => string|undefined} [opts.locate] - How a GraphViz
|
|
110
|
-
* engine's binary is found; the real lookup by default.
|
|
111
|
-
* @param {boolean} [opts.requireGraphviz=true] - Whether a missing engine is
|
|
112
|
-
* an error. False, the `.dot` files are written, the renderings skipped,
|
|
113
|
-
* and one warning returned.
|
|
114
106
|
* @returns {{written: string[], findings: Array<{file: string, line?: number,
|
|
115
|
-
* column?: number, severity: "error"|"warning", message: string}
|
|
116
|
-
*
|
|
117
|
-
* rendering was skipped for want of GraphViz.
|
|
107
|
+
* column?: number, severity: "error"|"warning", message: string}>}} What was
|
|
108
|
+
* written and what was found.
|
|
118
109
|
* @throws {Error} When no mode is asked for, a named place is not one, the
|
|
119
|
-
* horizon is not a marker of the scale
|
|
110
|
+
* horizon is not a marker of the scale.
|
|
120
111
|
*/
|
|
121
112
|
export function buildMaps({
|
|
122
113
|
world,
|
|
@@ -133,8 +124,6 @@ export function buildMaps({
|
|
|
133
124
|
ranksep,
|
|
134
125
|
scale = 1,
|
|
135
126
|
polities = true,
|
|
136
|
-
locate = findGraphviz,
|
|
137
|
-
requireGraphviz = true,
|
|
138
127
|
}) {
|
|
139
128
|
if (!tree && from.length === 0 && chart.length === 0 && !travel) {
|
|
140
129
|
throw new Error(
|
|
@@ -148,39 +137,15 @@ export function buildMaps({
|
|
|
148
137
|
}
|
|
149
138
|
const { places } = world;
|
|
150
139
|
|
|
151
|
-
// Every binary this run needs, found once and before anything is drawn,
|
|
152
|
-
// so a missing one is reported before a directory is touched.
|
|
153
|
-
const engines = new Set();
|
|
154
|
-
if (tree) engines.add(engine);
|
|
155
|
-
if (from.length || chart.length || travel) engines.add("neato");
|
|
156
|
-
/** @type {Map<string, string>} */
|
|
157
|
-
const binaries = new Map();
|
|
158
140
|
const findings = [];
|
|
159
|
-
let skipped = false;
|
|
160
|
-
for (const name of engines) {
|
|
161
|
-
const binary = locate(name);
|
|
162
|
-
if (binary) binaries.set(name, binary);
|
|
163
|
-
else if (requireGraphviz) throw new Error(graphvizMissingMessage(name));
|
|
164
|
-
else if (!skipped) {
|
|
165
|
-
skipped = true;
|
|
166
|
-
findings.push({
|
|
167
|
-
file: outDir,
|
|
168
|
-
severity: "warning",
|
|
169
|
-
message: `${graphvizMissingMessage(name)}; the .dot files are written and the renderings skipped`,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
141
|
fs.mkdirSync(outDir, { recursive: true });
|
|
175
142
|
const written = [];
|
|
176
|
-
const emit = (name, dot, engineName,
|
|
143
|
+
const emit = (name, dot, engineName, nop) => {
|
|
177
144
|
const dotPath = path.join(outDir, `${name}.dot`);
|
|
178
145
|
fs.writeFileSync(dotPath, dot);
|
|
179
146
|
written.push(dotPath);
|
|
180
|
-
const binary = binaries.get(engineName);
|
|
181
|
-
if (!binary) return;
|
|
182
147
|
const svgPath = path.join(outDir, `${name}.svg`);
|
|
183
|
-
const { warnings } = renderDot(dotPath, svgPath, {
|
|
148
|
+
const { warnings } = renderDot(dotPath, svgPath, { engine: engineName, nop });
|
|
184
149
|
written.push(svgPath);
|
|
185
150
|
if (warnings) {
|
|
186
151
|
findings.push({ file: dotPath, severity: "warning", message: warnings });
|
|
@@ -226,7 +191,7 @@ export function buildMaps({
|
|
|
226
191
|
message: `place "${place}" names "${to}" in a border or a route, and no place declares that shortcode; the map centred on "${centre}" leaves it out`,
|
|
227
192
|
});
|
|
228
193
|
}
|
|
229
|
-
emit(`from-${centre}`, fromDot(layout, places, { scale }), "neato",
|
|
194
|
+
emit(`from-${centre}`, fromDot(layout, places, { scale }), "neato", 2);
|
|
230
195
|
}
|
|
231
196
|
|
|
232
197
|
const viewpoints = chart.includes(FROM_ALL) ? relatedPlaces(places) : chart;
|
|
@@ -239,7 +204,7 @@ export function buildMaps({
|
|
|
239
204
|
message: `place "${place}" names "${to}" in a border or a route, and no place declares that shortcode; the chart centred on "${centre}" leaves it out`,
|
|
240
205
|
});
|
|
241
206
|
}
|
|
242
|
-
emit(`chart-${centre}`, fromDot(layout, places, { scale }), "neato",
|
|
207
|
+
emit(`chart-${centre}`, fromDot(layout, places, { scale }), "neato", 2);
|
|
243
208
|
}
|
|
244
209
|
|
|
245
210
|
if (travel) {
|
|
@@ -254,7 +219,7 @@ export function buildMaps({
|
|
|
254
219
|
emit("travel", travelDot(graph, places, { scale }), "neato");
|
|
255
220
|
}
|
|
256
221
|
|
|
257
|
-
return { written, findings
|
|
222
|
+
return { written, findings };
|
|
258
223
|
}
|
|
259
224
|
|
|
260
225
|
/**
|
package/engine/map-dot.mjs
CHANGED
|
@@ -330,7 +330,7 @@ function emitLegend(lines, scale) {
|
|
|
330
330
|
*/
|
|
331
331
|
function placeAttrs(place, analysis, scale) {
|
|
332
332
|
const onCycle = analysis.cycleNodes.has(place.shortcode);
|
|
333
|
-
const noParent = place.parents.length === 0 && place.shortcode
|
|
333
|
+
const noParent = place.parents.length === 0 && !analysis.worlds.includes(place.shortcode);
|
|
334
334
|
return styleAttrs(place.subType ?? "settlement", {
|
|
335
335
|
scale,
|
|
336
336
|
label: place.name,
|
|
@@ -399,7 +399,10 @@ export function treeDot({
|
|
|
399
399
|
scale = 1,
|
|
400
400
|
drawPolities = true,
|
|
401
401
|
}) {
|
|
402
|
-
const world =
|
|
402
|
+
const world =
|
|
403
|
+
root === undefined ? analysis.world
|
|
404
|
+
: analysis.worlds.includes(root) ? root
|
|
405
|
+
: places.get(root)?.parents.find((parent) => analysis.worlds.includes(parent));
|
|
403
406
|
let included;
|
|
404
407
|
let scopeLabel;
|
|
405
408
|
let worldIsContext = false;
|
|
@@ -439,7 +442,7 @@ export function treeDot({
|
|
|
439
442
|
lines.push(' edge [fontname="Helvetica", fontsize=8];');
|
|
440
443
|
const heading =
|
|
441
444
|
title ??
|
|
442
|
-
`${scopeLabel ?? (world ? places.get(world)?.name : "The
|
|
445
|
+
`${scopeLabel ?? (world ? places.get(world)?.name : "The worlds")} — containment tree`;
|
|
443
446
|
lines.push(` label=${dotString(heading)};`);
|
|
444
447
|
lines.push(' labelloc=t; fontsize=18; fontname="Helvetica-Bold";');
|
|
445
448
|
lines.push("");
|