@rpgm-tools/neo-angband-mod-sdk 0.10.0 → 0.12.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.
- package/bin/neo-angband-mod-build.mjs +331 -0
- package/dist/engine.d.ts +62 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +88 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +72 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +116 -8
- package/dist/loader.js.map +1 -1
- package/dist/semver.d.ts +19 -0
- package/dist/semver.d.ts.map +1 -1
- package/dist/semver.js +30 -0
- package/dist/semver.js.map +1 -1
- package/package.json +16 -1
- package/src/engine.ts +113 -0
- package/src/index.ts +5 -3
- package/src/loader.ts +158 -11
- package/src/semver.ts +30 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build a mod's TypeScript into the `plugin.js` its folder ships.
|
|
4
|
+
*
|
|
5
|
+
* neo-angband-mod-build [--root <dir>] [--mods a,b] [--out <dir>] [--check]
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS. A mod is distributed as a FOLDER: manifest.json plus plugin.js, an
|
|
8
|
+
* ES module the game imports from wherever that folder ended up - a loopback URL on
|
|
9
|
+
* desktop, a blob: from a browser directory picker, IndexedDB for one installed from a
|
|
10
|
+
* repository. The source is TypeScript, and TypeScript is not a thing a browser
|
|
11
|
+
* imports, so something has to do the transform.
|
|
12
|
+
*
|
|
13
|
+
* WHY IT LIVES IN THE SDK. It used to live in the engine repository, next to the mods
|
|
14
|
+
* that were bundled into the app - which meant the only way to build a plugin.js was
|
|
15
|
+
* to have the whole engine repository checked out. Now that every mod lives in its own
|
|
16
|
+
* repository, including the first-party ones, that is backwards: the rules below are
|
|
17
|
+
* the plugin ABI, the ABI belongs to the SDK, and the SDK is published. A mod repo
|
|
18
|
+
* installs @rpgm-tools/neo-angband-mod-sdk and runs this. Copying the script into each
|
|
19
|
+
* mod repo was the alternative, and it would have put three drifting copies of the
|
|
20
|
+
* same three guarantees in three places.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT MUST GUARANTEE, and why each is checked rather than assumed:
|
|
23
|
+
*
|
|
24
|
+
* 1. NO BARE IMPORTS, AND NO INLINED ENGINE. "@rpgm-tools/neo-angband-core" does not
|
|
25
|
+
* resolve in a module fetched from a folder - it resolves against the document,
|
|
26
|
+
* where nothing is published. The engine arrives as `ctx.core` instead. A mod's
|
|
27
|
+
* source may import core for TYPES, which esbuild erases.
|
|
28
|
+
*
|
|
29
|
+
* Every non-relative specifier is therefore marked EXTERNAL, and a surviving one
|
|
30
|
+
* is fatal. That is not a detail. Without it, esbuild RESOLVES the import (a mod
|
|
31
|
+
* repo has core as a devDependency, so it is right there in node_modules) and
|
|
32
|
+
* inlines what it finds - and then this scan can never fire, because there is no
|
|
33
|
+
* bare import left to see. Measured on the engine repo's own script, which did
|
|
34
|
+
* not mark anything external: a plugin.ts doing `import { TMD } from
|
|
35
|
+
* "@rpgm-tools/neo-angband-core"` built clean, exit 0, and shipped a private copy
|
|
36
|
+
* of the timed-effect table inside plugin.js. For a frozen constant that is
|
|
37
|
+
* merely wasteful; for anything with module state - a registry, a cache, the RNG -
|
|
38
|
+
* it is a SECOND INSTANCE of the engine's state living inside the mod, which is
|
|
39
|
+
* the exact failure the ABI's "the engine is passed in" rule exists to prevent.
|
|
40
|
+
* A guard that cannot fail is worse than no guard: it reads as coverage.
|
|
41
|
+
*
|
|
42
|
+
* 2. ONE FILE. The ABI permits relative imports between a mod's own scripts, but
|
|
43
|
+
* bundling them is strictly better for a distributed artefact: one request, one
|
|
44
|
+
* digest, and no chance of a half-downloaded dependency graph.
|
|
45
|
+
* 3. A DEFAULT EXPORT THAT LOOKS LIKE A ModPlugin. The host validates this at load
|
|
46
|
+
* (validateModPlugin), but a broken artefact should fail HERE, where there is a
|
|
47
|
+
* build log, rather than as one line in a mod manager.
|
|
48
|
+
* 4. THE COMMITTED plugin.js IS CURRENT. In a mod repository plugin.js is a committed
|
|
49
|
+
* artefact - it has to be, because that is the file the catalogue fetches at a tag
|
|
50
|
+
* and hashes. Which means it can go stale against its own source, silently, and the
|
|
51
|
+
* stale copy is the one players run. So `--check` compares byte for byte against
|
|
52
|
+
* whatever plugin.js is already there and fails on a difference. Nothing else in
|
|
53
|
+
* the chain would notice: the digest would match the stale file perfectly.
|
|
54
|
+
*
|
|
55
|
+
* `--check` builds and verifies without writing, for CI.
|
|
56
|
+
*
|
|
57
|
+
* TWO SHAPES OF --root, decided by what is in it:
|
|
58
|
+
*
|
|
59
|
+
* a mod folder (has plugin.ts) build it, writing beside its manifest.json
|
|
60
|
+
* a folder OF mod folders build each one that has a plugin.ts
|
|
61
|
+
*
|
|
62
|
+
* The first is what a mod repository wants: plugin.js is a committed artefact there,
|
|
63
|
+
* because that is the file the catalogue fetches and hashes. The second is what the
|
|
64
|
+
* engine repository wants for its demo mods, and writes to --out.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
68
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
69
|
+
|
|
70
|
+
/* esbuild is not a dependency of this package. The SDK is imported at RUNTIME by the
|
|
71
|
+
* web build to validate manifests, and a native binary in that dependency tree is a
|
|
72
|
+
* cost every consumer pays for a tool only mod authors run. So it is resolved when the
|
|
73
|
+
* tool actually runs, and its absence names the remedy instead of stack-tracing. */
|
|
74
|
+
let build;
|
|
75
|
+
try {
|
|
76
|
+
({ build } = await import("esbuild"));
|
|
77
|
+
} catch {
|
|
78
|
+
console.error(
|
|
79
|
+
"[mod-build] esbuild is not installed. It is the transform this tool drives, and\n" +
|
|
80
|
+
"[mod-build] it is deliberately not a dependency of the SDK (a native binary in\n" +
|
|
81
|
+
"[mod-build] the runtime tree costs every consumer for a build-time tool).\n" +
|
|
82
|
+
"[mod-build] Add it: npm i -D esbuild",
|
|
83
|
+
);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const args = process.argv.slice(2);
|
|
88
|
+
const flag = (name, fallback) => {
|
|
89
|
+
const at = args.indexOf(`--${name}`);
|
|
90
|
+
return at >= 0 && args[at + 1] !== undefined ? args[at + 1] : fallback;
|
|
91
|
+
};
|
|
92
|
+
const check = args.includes("--check");
|
|
93
|
+
const root = resolve(process.cwd(), flag("root", "."));
|
|
94
|
+
|
|
95
|
+
function note(message) {
|
|
96
|
+
console.log(`[mod-build] ${message}`);
|
|
97
|
+
}
|
|
98
|
+
let failed = false;
|
|
99
|
+
function fail(message) {
|
|
100
|
+
console.error(`[mod-build] ${message}`);
|
|
101
|
+
failed = true;
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
106
|
+
fail(`FATAL - --root ${root} is not a directory`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The mod folders to build, and where each one's output goes.
|
|
112
|
+
*
|
|
113
|
+
* `single` is the mod-repository case, and its default output is the folder itself:
|
|
114
|
+
* plugin.js is a committed artefact there, sitting next to the manifest.json it is
|
|
115
|
+
* distributed with. Writing it anywhere else would leave the repo's own copy stale,
|
|
116
|
+
* which is the one state nobody looks at.
|
|
117
|
+
*/
|
|
118
|
+
const single = existsSync(join(root, "plugin.ts"));
|
|
119
|
+
const outFlag = flag("out", null);
|
|
120
|
+
let targets;
|
|
121
|
+
if (single) {
|
|
122
|
+
const id = readManifestId(root) ?? basename(root);
|
|
123
|
+
targets = [{ id, dir: root, out: outFlag === null ? root : resolve(process.cwd(), outFlag, id) }];
|
|
124
|
+
} else {
|
|
125
|
+
const all = readdirSync(root)
|
|
126
|
+
.filter((id) => existsSync(join(root, id, "plugin.ts")))
|
|
127
|
+
.sort();
|
|
128
|
+
const requested = flag("mods", "")
|
|
129
|
+
.split(",")
|
|
130
|
+
.map((s) => s.trim())
|
|
131
|
+
.filter((s) => s !== "");
|
|
132
|
+
for (const id of requested) {
|
|
133
|
+
if (!all.includes(id)) {
|
|
134
|
+
/* A typo'd id is a caller bug, not a missing artefact: name what does exist
|
|
135
|
+
* rather than build nothing and exit 0. */
|
|
136
|
+
fail(`FATAL - no mod '${id}' with a plugin.ts under ${root} (have: ${all.join(", ") || "none"})`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const selected = requested.length > 0 ? requested : all;
|
|
141
|
+
const outRoot = resolve(process.cwd(), outFlag ?? join(root, "..", "build", "mod-plugins"));
|
|
142
|
+
targets = selected.map((id) => ({ id, dir: join(root, id), out: join(outRoot, id) }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (targets.length === 0) {
|
|
146
|
+
/* Not an error - a repo may legitimately hold a content-only mod. But it is said out
|
|
147
|
+
* loud, because "nothing to build" and "built everything" are the same exit code and
|
|
148
|
+
* a CI step that quietly does nothing reads as a passing check. */
|
|
149
|
+
note(`no mod under ${root} ships a plugin.ts; nothing to build`);
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
note(`${check ? "checking" : "building"} ${targets.length}: ${targets.map((t) => t.id).join(", ")}`);
|
|
154
|
+
|
|
155
|
+
/** The manifest's own id, or null when there is no readable manifest. */
|
|
156
|
+
function readManifestId(dir) {
|
|
157
|
+
try {
|
|
158
|
+
const id = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8")).id;
|
|
159
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A bare (package) specifier in the bundle.
|
|
167
|
+
*
|
|
168
|
+
* Matches the specifier position of a static import/export-from and of a dynamic
|
|
169
|
+
* `import("...")`, then keeps the ones that are not relative or absolute. Deliberately
|
|
170
|
+
* not a full parse: esbuild has produced one module of its own output, and the only
|
|
171
|
+
* imports left are the ones it was told to leave external - which is every bare one,
|
|
172
|
+
* on purpose. See guarantee 1.
|
|
173
|
+
*/
|
|
174
|
+
function bareImports(code) {
|
|
175
|
+
const specifiers = [
|
|
176
|
+
...code.matchAll(/(?:^|[\s;}])(?:import|export)[\s\S]{0,200}?from\s*["']([^"']+)["']/g),
|
|
177
|
+
...code.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g),
|
|
178
|
+
...code.matchAll(/(?:^|[\s;}])import\s*["']([^"']+)["']/g),
|
|
179
|
+
].map((m) => m[1]);
|
|
180
|
+
return [...new Set(specifiers.filter((s) => !PATH_LIKE.test(s)))];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Relative, POSIX-absolute, or Windows drive-absolute. Anything else is a package. */
|
|
184
|
+
const PATH_LIKE = /^(?:\.|\/|[A-Za-z]:[\\/])/;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Leave every package specifier alone.
|
|
188
|
+
*
|
|
189
|
+
* `external: [...]` would need the names up front, and the point is to catch names
|
|
190
|
+
* nobody predicted - a mod that reaches for lodash cannot resolve it from a mod folder
|
|
191
|
+
* either. So the filter is "everything", narrowed here to specifiers that are not paths.
|
|
192
|
+
*
|
|
193
|
+
* The entry point is exempt explicitly. It arrives through onResolve like any other
|
|
194
|
+
* specifier, as an ABSOLUTE path - which on Windows begins with a drive letter and so
|
|
195
|
+
* is not relative by the naive test. Marking it external makes esbuild refuse the build
|
|
196
|
+
* outright ("the entry point cannot be marked as external"), which is how this was
|
|
197
|
+
* found: all three fixtures failed identically, including the one that must pass.
|
|
198
|
+
*/
|
|
199
|
+
const externalAll = {
|
|
200
|
+
name: "external-bare-specifiers",
|
|
201
|
+
setup(b) {
|
|
202
|
+
b.onResolve({ filter: /.*/ }, (a) => {
|
|
203
|
+
if (a.kind === "entry-point" || PATH_LIKE.test(a.path)) return null;
|
|
204
|
+
return { path: a.path, external: true };
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const { id, dir, out } of targets) {
|
|
210
|
+
let js;
|
|
211
|
+
try {
|
|
212
|
+
const result = await build({
|
|
213
|
+
entryPoints: [join(dir, "plugin.ts")],
|
|
214
|
+
bundle: true,
|
|
215
|
+
format: "esm",
|
|
216
|
+
platform: "browser",
|
|
217
|
+
target: "es2022",
|
|
218
|
+
/* Readable output on purpose. A player can open plugin.js in the mod folder
|
|
219
|
+
* they installed, and a mod they cannot read is a mod they cannot trust; the
|
|
220
|
+
* few KB this costs are not worth the opacity. */
|
|
221
|
+
minify: false,
|
|
222
|
+
write: false,
|
|
223
|
+
legalComments: "inline",
|
|
224
|
+
plugins: [externalAll],
|
|
225
|
+
banner: {
|
|
226
|
+
js:
|
|
227
|
+
`// ${id} - generated from plugin.ts by neo-angband-mod-build\n` +
|
|
228
|
+
`// (@rpgm-tools/neo-angband-mod-sdk). Edit the TypeScript source, not this file.`,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
js = result.outputFiles[0].text;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
fail(`${id}: build failed - ${e.message}`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const bare = bareImports(js);
|
|
238
|
+
if (bare.length > 0) {
|
|
239
|
+
/* The failure this catches is invisible in a dev bundle and total in a player's
|
|
240
|
+
* install, so it names the fix rather than just the problem. */
|
|
241
|
+
fail(
|
|
242
|
+
`${id}: plugin.js imports ${bare.map((s) => `"${s}"`).join(", ")} - a module ` +
|
|
243
|
+
`loaded from a mod folder cannot resolve a package by name, and bundling one ` +
|
|
244
|
+
`in would give the mod its own copy of that module's state. Take what you ` +
|
|
245
|
+
`need from ctx.core, and import @rpgm-tools/neo-angband-core for TYPES only ` +
|
|
246
|
+
`("import type { ... }").`,
|
|
247
|
+
);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* Shape check. Importing the built module is the only way to see the default
|
|
252
|
+
* export, and it is safe here: this is the author's own code, just compiled. */
|
|
253
|
+
const dataUrl = `data:text/javascript;base64,${Buffer.from(js, "utf8").toString("base64")}`;
|
|
254
|
+
let plugin;
|
|
255
|
+
try {
|
|
256
|
+
plugin = (await import(dataUrl)).default;
|
|
257
|
+
} catch (e) {
|
|
258
|
+
fail(`${id}: plugin.js does not import cleanly - ${e.message}`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const wrong = pluginProblem(plugin);
|
|
262
|
+
if (wrong) {
|
|
263
|
+
fail(`${id}: ${wrong}`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (check) {
|
|
268
|
+
const committed = join(out, "plugin.js");
|
|
269
|
+
if (existsSync(committed)) {
|
|
270
|
+
/* Guarantee 4. A stale committed artefact passes every other check in this file
|
|
271
|
+
* and every digest in the catalogue, because the digest is taken FROM it. */
|
|
272
|
+
const have = readFileSync(committed, "utf8");
|
|
273
|
+
if (have !== js) {
|
|
274
|
+
fail(
|
|
275
|
+
`${id}: the committed ${relative(process.cwd(), committed)} does not match its ` +
|
|
276
|
+
`source. It is what players actually run, and a digest taken from it would ` +
|
|
277
|
+
`match it perfectly. Rebuild it and commit the result.`,
|
|
278
|
+
);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
note(`${id}: ok, and the committed plugin.js is current (${(js.length / 1024).toFixed(1)} KiB, api ${plugin.api})`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
note(`${id}: ok (${(js.length / 1024).toFixed(1)} KiB, api ${plugin.api})`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
mkdirSync(out, { recursive: true });
|
|
288
|
+
writeFileSync(join(out, "plugin.js"), js, "utf8");
|
|
289
|
+
if (resolve(out) !== resolve(dir)) {
|
|
290
|
+
/* The manifest travels with it: a mod folder without one is not a mod, and the
|
|
291
|
+
* shared validator (readModDir) requires a top-level manifest.json from every
|
|
292
|
+
* source alike. Copied verbatim - this tool is not in the business of editing what
|
|
293
|
+
* a mod declares, and a rewritten id would install under the wrong name. */
|
|
294
|
+
writeFileSync(
|
|
295
|
+
join(out, "manifest.json"),
|
|
296
|
+
readFileSync(join(dir, "manifest.json"), "utf8"),
|
|
297
|
+
"utf8",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
note(
|
|
301
|
+
`${id}: wrote ${relative(process.cwd(), join(out, "plugin.js"))} ` +
|
|
302
|
+
`(${(js.length / 1024).toFixed(1)} KiB, api ${plugin.api})`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (failed) process.exitCode = 1;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* What is wrong with a built default export, or null.
|
|
310
|
+
*
|
|
311
|
+
* The same rules as the host's validateModPlugin, restated here rather than imported
|
|
312
|
+
* because that module lives in the web front end and this is a plain script the SDK
|
|
313
|
+
* ships. The duplication is two field checks; importing a compiled copy would tie the
|
|
314
|
+
* build step to whether the front end happens to have been built.
|
|
315
|
+
*/
|
|
316
|
+
function pluginProblem(plugin) {
|
|
317
|
+
if (plugin === null || plugin === undefined) return "plugin.js has no default export";
|
|
318
|
+
if (typeof plugin !== "object" && typeof plugin !== "function") {
|
|
319
|
+
return `plugin.js default-exports a ${typeof plugin}, not a plugin object`;
|
|
320
|
+
}
|
|
321
|
+
if (!Number.isInteger(plugin.api)) return 'plugin.js declares no integer "api" version';
|
|
322
|
+
if (plugin.hooks === undefined && plugin.register === undefined) {
|
|
323
|
+
return "plugin.js declares neither hooks nor register, so it would do nothing";
|
|
324
|
+
}
|
|
325
|
+
for (const name of ["hooks", "register", "uninstall"]) {
|
|
326
|
+
if (plugin[name] !== undefined && typeof plugin[name] !== "function") {
|
|
327
|
+
return `plugin.js: ${name} is not a function`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ENGINE gate: may a pack load on this build of the game?
|
|
3
|
+
*
|
|
4
|
+
* A manifest's `engine` is a semver RANGE over the engine's version
|
|
5
|
+
* (core's ENGINE_VERSION), declared by the pack author to say which builds their
|
|
6
|
+
* pack was written for. It has existed on PackManifest since the manifest was
|
|
7
|
+
* written, every discovery path in the host carefully carries it through
|
|
8
|
+
* normalisation - and until now NOTHING read it. `satisfies()` had exactly one
|
|
9
|
+
* caller, resolve.ts, for mod-to-mod `dependencies`.
|
|
10
|
+
*
|
|
11
|
+
* A range nothing evaluates is worse than no field at all, because authors fill it
|
|
12
|
+
* in and believe it. Two of the three first-party mods had drifted to
|
|
13
|
+
* `"engine": "4.2.x"` - the ANGBAND baseline (PARITY_BASELINE), not the port's
|
|
14
|
+
* version - and no test, no build and no boot could notice, because no code path
|
|
15
|
+
* ever compared it to anything.
|
|
16
|
+
*
|
|
17
|
+
* SEPARATE FROM `modApi`, and deliberately so; manifest.ts documents why at the
|
|
18
|
+
* field. The two answer different questions and must not be merged:
|
|
19
|
+
*
|
|
20
|
+
* engine a RANGE over the game's version, declared by any pack. "This content
|
|
21
|
+
* was written for these builds." A patch release moves the game's
|
|
22
|
+
* version and not the plugin ABI, so a range is the right shape.
|
|
23
|
+
* modApi an exact INTEGER, required of a pack shipping plugin.js, matched
|
|
24
|
+
* exactly, because the ABI is unstable before 1.0 and a range would
|
|
25
|
+
* promise a compatibility that does not exist.
|
|
26
|
+
*
|
|
27
|
+
* A pack can fail either, both, or neither, and the reasons a player must read are
|
|
28
|
+
* different in each case - so this returns a discriminated verdict rather than a
|
|
29
|
+
* boolean, and the caller keeps them apart.
|
|
30
|
+
*/
|
|
31
|
+
import type { PackManifest } from "./manifest.js";
|
|
32
|
+
/**
|
|
33
|
+
* Why a pack may not load here - or that it may.
|
|
34
|
+
*
|
|
35
|
+
* `kind` rather than only prose, because the two failures have different AUDIENCES
|
|
36
|
+
* and a caller may want to route them differently: `out-of-range` is a message for
|
|
37
|
+
* the PLAYER (nothing is broken; the versions do not line up), and `bad-manifest`
|
|
38
|
+
* is a message for the pack's AUTHOR (the manifest does not say anything
|
|
39
|
+
* evaluable). Asserting on `kind` also keeps the tests off the wording.
|
|
40
|
+
*/
|
|
41
|
+
export type EngineVerdict = {
|
|
42
|
+
readonly ok: true;
|
|
43
|
+
} | {
|
|
44
|
+
readonly ok: false;
|
|
45
|
+
readonly kind: "out-of-range" | "bad-manifest";
|
|
46
|
+
readonly why: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Does `engineVersion` satisfy the pack's declared `engine` range?
|
|
50
|
+
*
|
|
51
|
+
* An ABSENT range is allowed, and that is not laxity: `engine` is optional on
|
|
52
|
+
* PackManifest, most packs are pure data that no engine release breaks, and a pack
|
|
53
|
+
* that declines to guess at future compatibility is making a reasonable choice. An
|
|
54
|
+
* EMPTY OR MALFORMED range is not the same thing - the author meant to say
|
|
55
|
+
* something and said something unreadable - so it is refused as an author error
|
|
56
|
+
* rather than quietly treated as absent.
|
|
57
|
+
*
|
|
58
|
+
* Pure, and takes the version as an argument rather than importing it, so a test
|
|
59
|
+
* can drive any build and so mod-sdk stays independent of core.
|
|
60
|
+
*/
|
|
61
|
+
export declare function engineVerdict(manifest: Pick<PackManifest, "engine">, engineVersion: string): EngineVerdict;
|
|
62
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAGlD;;;;;;;;GAQG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAA;CAAE,GACrB;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CAAC;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB,CAAC;AAIN;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,EACtC,aAAa,EAAE,MAAM,GACpB,aAAa,CAuCf"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ENGINE gate: may a pack load on this build of the game?
|
|
3
|
+
*
|
|
4
|
+
* A manifest's `engine` is a semver RANGE over the engine's version
|
|
5
|
+
* (core's ENGINE_VERSION), declared by the pack author to say which builds their
|
|
6
|
+
* pack was written for. It has existed on PackManifest since the manifest was
|
|
7
|
+
* written, every discovery path in the host carefully carries it through
|
|
8
|
+
* normalisation - and until now NOTHING read it. `satisfies()` had exactly one
|
|
9
|
+
* caller, resolve.ts, for mod-to-mod `dependencies`.
|
|
10
|
+
*
|
|
11
|
+
* A range nothing evaluates is worse than no field at all, because authors fill it
|
|
12
|
+
* in and believe it. Two of the three first-party mods had drifted to
|
|
13
|
+
* `"engine": "4.2.x"` - the ANGBAND baseline (PARITY_BASELINE), not the port's
|
|
14
|
+
* version - and no test, no build and no boot could notice, because no code path
|
|
15
|
+
* ever compared it to anything.
|
|
16
|
+
*
|
|
17
|
+
* SEPARATE FROM `modApi`, and deliberately so; manifest.ts documents why at the
|
|
18
|
+
* field. The two answer different questions and must not be merged:
|
|
19
|
+
*
|
|
20
|
+
* engine a RANGE over the game's version, declared by any pack. "This content
|
|
21
|
+
* was written for these builds." A patch release moves the game's
|
|
22
|
+
* version and not the plugin ABI, so a range is the right shape.
|
|
23
|
+
* modApi an exact INTEGER, required of a pack shipping plugin.js, matched
|
|
24
|
+
* exactly, because the ABI is unstable before 1.0 and a range would
|
|
25
|
+
* promise a compatibility that does not exist.
|
|
26
|
+
*
|
|
27
|
+
* A pack can fail either, both, or neither, and the reasons a player must read are
|
|
28
|
+
* different in each case - so this returns a discriminated verdict rather than a
|
|
29
|
+
* boolean, and the caller keeps them apart.
|
|
30
|
+
*/
|
|
31
|
+
import { satisfies } from "./semver.js";
|
|
32
|
+
const OK = { ok: true };
|
|
33
|
+
/**
|
|
34
|
+
* Does `engineVersion` satisfy the pack's declared `engine` range?
|
|
35
|
+
*
|
|
36
|
+
* An ABSENT range is allowed, and that is not laxity: `engine` is optional on
|
|
37
|
+
* PackManifest, most packs are pure data that no engine release breaks, and a pack
|
|
38
|
+
* that declines to guess at future compatibility is making a reasonable choice. An
|
|
39
|
+
* EMPTY OR MALFORMED range is not the same thing - the author meant to say
|
|
40
|
+
* something and said something unreadable - so it is refused as an author error
|
|
41
|
+
* rather than quietly treated as absent.
|
|
42
|
+
*
|
|
43
|
+
* Pure, and takes the version as an argument rather than importing it, so a test
|
|
44
|
+
* can drive any build and so mod-sdk stays independent of core.
|
|
45
|
+
*/
|
|
46
|
+
export function engineVerdict(manifest, engineVersion) {
|
|
47
|
+
const range = manifest.engine;
|
|
48
|
+
if (range === undefined)
|
|
49
|
+
return OK;
|
|
50
|
+
let matched;
|
|
51
|
+
try {
|
|
52
|
+
matched = satisfies(engineVersion, range);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
/* satisfies() throws SemverError for an empty range and for any token it cannot
|
|
56
|
+
* parse. It also throws if `engineVersion` itself is unparseable, which would be
|
|
57
|
+
* the GAME's fault and not the pack's - but that is a build-time impossibility
|
|
58
|
+
* (core's ENGINE_VERSION is a literal) and pretending to distinguish it here
|
|
59
|
+
* would mean claiming to know which of the two strings was wrong. The range is
|
|
60
|
+
* the one an author can fix, and the message quotes it so they can see it. */
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
kind: "bad-manifest",
|
|
64
|
+
why: `declares "engine": ${JSON.stringify(range)}, which is not a version range ` +
|
|
65
|
+
`this game can read (${message(e)}) - the manifest needs fixing`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (matched)
|
|
69
|
+
return OK;
|
|
70
|
+
/* BOTH versions, and no claim about which side is behind. The modApi gate above it
|
|
71
|
+
* does say which way round, and it can: it compares two integers and the larger one
|
|
72
|
+
* is the newer. A RANGE does not give that for free - a mod wanting `^0.20.0` on a
|
|
73
|
+
* 0.10.0 build needs a newer GAME, one wanting `<0.5.0` needs a newer MOD, and
|
|
74
|
+
* telling them apart means computing the range's bounds, which this matcher does not
|
|
75
|
+
* expose. Naming the pair says everything true and nothing invented; a confident
|
|
76
|
+
* "the mod needs updating" would be wrong half the time, on the one line the player
|
|
77
|
+
* acts on. */
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
kind: "out-of-range",
|
|
81
|
+
why: `was written for engine ${range}, and this game is ${engineVersion} - ` +
|
|
82
|
+
`the two do not line up, so one of them needs an update before it can load`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function message(e) {
|
|
86
|
+
return e instanceof Error ? e.message : String(e);
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAmBxC,MAAM,EAAE,GAAkB,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AAEvC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,aAAa,CAC3B,QAAsC,EACtC,aAAqB;IAErB,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC;IAC9B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IAEnC,IAAI,OAAgB,CAAC;IACrB,IAAI,CAAC;QACH,OAAO,GAAG,SAAS,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;IAC5C,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX;;;;;sFAK8E;QAC9E,OAAO;YACL,EAAE,EAAE,KAAK;YACT,IAAI,EAAE,cAAc;YACpB,GAAG,EACD,sBAAsB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,iCAAiC;gBAC5E,uBAAuB,OAAO,CAAC,CAAC,CAAC,+BAA+B;SACnE,CAAC;IACJ,CAAC;IACD,IAAI,OAAO;QAAE,OAAO,EAAE,CAAC;IAEvB;;;;;;;kBAOc;IACd,OAAO;QACL,EAAE,EAAE,KAAK;QACT,IAAI,EAAE,cAAc;QACpB,GAAG,EACD,0BAA0B,KAAK,sBAAsB,aAAa,KAAK;YACvE,2EAA2E;KAC9E,CAAC;AACJ,CAAC;AAED,SAAS,OAAO,CAAC,CAAU;IACzB,OAAO,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACpD,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
export { hasFacet, ManifestError, PACK_SHAPES, packFacets, packRef, slugify, validateManifest, } from "./manifest.js";
|
|
16
16
|
export type { Capability, PackManifest, PackRef, PackRule, PackShape, PackTilePack, } from "./manifest.js";
|
|
17
17
|
export { ResolveError, resolveLoadOrder } from "./resolve.js";
|
|
18
|
-
export { satisfies, SemverError } from "./semver.js";
|
|
18
|
+
export { compareSemver, satisfies, SemverError } from "./semver.js";
|
|
19
|
+
export { engineVerdict } from "./engine.js";
|
|
20
|
+
export type { EngineVerdict } from "./engine.js";
|
|
19
21
|
export { ComposeError, composePacks, mergePatch } from "./compose.js";
|
|
20
|
-
export { composeContentPacks } from "./loader.js";
|
|
21
|
-
export type { ComposedContent, LoadedPack } from "./loader.js";
|
|
22
|
+
export { composeContentPacks, composeDroppingBroken } from "./loader.js";
|
|
23
|
+
export type { ComposedContent, ComposeFault, DroppedPack, LoadedPack } from "./loader.js";
|
|
22
24
|
export { KEYED_RECORD_FILES, keyDescription, keySpecFor, RECORD_KEY_SPECS, recordKey, } from "./record-key.js";
|
|
23
25
|
export type { RecordKeySpec } from "./record-key.js";
|
|
24
26
|
export type { ComposedRecord, FileContribution, JsonRecord, JsonValue, PackContent, } from "./compose.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,UAAU,EACV,OAAO,EACP,OAAO,EACP,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,UAAU,EACV,YAAY,EACZ,OAAO,EACP,QAAQ,EACR,SAAS,EACT,YAAY,GACb,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC9D,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,UAAU,EACV,OAAO,EACP,OAAO,EACP,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,UAAU,EACV,YAAY,EACZ,OAAO,EACP,QAAQ,EACR,SAAS,EACT,YAAY,GACb,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC9D,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACzE,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC1F,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,SAAS,GACV,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,YAAY,EACV,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,SAAS,EACT,WAAW,GACZ,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,aAAa,EACb,aAAa,EACb,OAAO,EACP,UAAU,GACX,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,YAAY,EACV,cAAc,EACd,UAAU,EACV,cAAc,EACd,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpF,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { hasFacet, ManifestError, PACK_SHAPES, packFacets, packRef, slugify, validateManifest, } from "./manifest.js";
|
|
16
16
|
export { ResolveError, resolveLoadOrder } from "./resolve.js";
|
|
17
|
-
export { satisfies, SemverError } from "./semver.js";
|
|
17
|
+
export { compareSemver, satisfies, SemverError } from "./semver.js";
|
|
18
|
+
export { engineVerdict } from "./engine.js";
|
|
18
19
|
export { ComposeError, composePacks, mergePatch } from "./compose.js";
|
|
19
|
-
export { composeContentPacks } from "./loader.js";
|
|
20
|
+
export { composeContentPacks, composeDroppingBroken } from "./loader.js";
|
|
20
21
|
export { KEYED_RECORD_FILES, keyDescription, keySpecFor, RECORD_KEY_SPECS, recordKey, } from "./record-key.js";
|
|
21
22
|
export { applyFieldPatch, composeFieldPatches, PatchError, touchedFields, } from "./patch.js";
|
|
22
23
|
export { computeConflictReport } from "./conflicts.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,UAAU,EACV,OAAO,EACP,OAAO,EACP,gBAAgB,GACjB,MAAM,eAAe,CAAC;AASvB,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC9D,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,QAAQ,EACR,aAAa,EACb,WAAW,EACX,UAAU,EACV,OAAO,EACP,OAAO,EACP,gBAAgB,GACjB,MAAM,eAAe,CAAC;AASvB,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC9D,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzE,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,SAAS,GACV,MAAM,iBAAiB,CAAC;AASzB,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAC;AAOpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAOvD,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/loader.d.ts
CHANGED
|
@@ -46,6 +46,22 @@
|
|
|
46
46
|
* blank page rather than a message. It is the same shape of channel the pack
|
|
47
47
|
* readers already use (`problems: readonly string[]`), so a host concatenates it
|
|
48
48
|
* into the list it already shows.
|
|
49
|
+
*
|
|
50
|
+
* THAT PARAGRAPH WAS ONLY TRUE OF THIS FILE'S OWN REFUSALS, and it read as a
|
|
51
|
+
* property of composition (2026-07-31). `composePacks` throws ComposeError on a
|
|
52
|
+
* patch whose target does not exist, and `resolveLoadOrder` throws on a missing
|
|
53
|
+
* dependency or a cycle - both reached from composeContentPacks, both from a
|
|
54
|
+
* mod's manifest or contribution, and the host does compose at module scope with
|
|
55
|
+
* no try. So the one class of mod mistake that stayed loud took the whole game to
|
|
56
|
+
* a blank page with nothing on screen naming the mod. `composeDroppingBroken`
|
|
57
|
+
* below is the answer, and it is the rule the rest of the mod system already
|
|
58
|
+
* follows: one broken mod costs that mod.
|
|
59
|
+
*
|
|
60
|
+
* `faults` carries the same refusals as `problems` with the pack id kept SEPARATE
|
|
61
|
+
* rather than prefixed into the sentence. A host that wants to show a mod its own
|
|
62
|
+
* problems on its own row cannot get that back out of a formatted line without
|
|
63
|
+
* parsing this file's message format - which is how a UI comes to depend on
|
|
64
|
+
* punctuation.
|
|
49
65
|
*/
|
|
50
66
|
import type { PackManifest } from "./manifest.js";
|
|
51
67
|
import type { FileContribution } from "./compose.js";
|
|
@@ -58,6 +74,16 @@ export interface LoadedPack {
|
|
|
58
74
|
/** fileName -> that file's contribution (records / patches / ...). */
|
|
59
75
|
files: Record<string, FileContribution>;
|
|
60
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* One refused operation with the pack that asked for it kept separate from the
|
|
79
|
+
* sentence, so a host can put a mod's own problems on that mod's own row.
|
|
80
|
+
*/
|
|
81
|
+
export interface ComposeFault {
|
|
82
|
+
/** The pack whose operation was refused. */
|
|
83
|
+
packId: string;
|
|
84
|
+
/** What could not be honoured, with no id prefix. */
|
|
85
|
+
why: string;
|
|
86
|
+
}
|
|
61
87
|
/** The merged content: per-file record arrays, in deterministic order. */
|
|
62
88
|
export interface ComposedContent {
|
|
63
89
|
/** fileName -> composed record array. */
|
|
@@ -72,6 +98,12 @@ export interface ComposedContent {
|
|
|
72
98
|
* A host shows these next to the pack-reading problems it already collects.
|
|
73
99
|
*/
|
|
74
100
|
problems: string[];
|
|
101
|
+
/**
|
|
102
|
+
* The same refusals, attributed. One entry per line in `problems`, in the same
|
|
103
|
+
* order, with `packId` split out - so a mod manager can show a mod what IT got
|
|
104
|
+
* wrong without parsing a sentence.
|
|
105
|
+
*/
|
|
106
|
+
faults: ComposeFault[];
|
|
75
107
|
}
|
|
76
108
|
/**
|
|
77
109
|
* Compose a set of loaded packs into merged per-file record arrays. With a
|
|
@@ -80,4 +112,44 @@ export interface ComposedContent {
|
|
|
80
112
|
* unchanged, so routing the base game through this path is a no-op.
|
|
81
113
|
*/
|
|
82
114
|
export declare function composeContentPacks(packs: readonly LoadedPack[]): ComposedContent;
|
|
115
|
+
/** One pack that was left out of a composition, and why. */
|
|
116
|
+
export interface DroppedPack {
|
|
117
|
+
/** The pack's id. */
|
|
118
|
+
readonly id: string;
|
|
119
|
+
/** What it did that could not be composed, as the thrower said it. */
|
|
120
|
+
readonly why: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Compose, dropping any pack whose contribution or manifest makes composition
|
|
124
|
+
* IMPOSSIBLE, and reporting which ones went.
|
|
125
|
+
*
|
|
126
|
+
* WHY THIS EXISTS. Everything in this file reports rather than throws, and that
|
|
127
|
+
* made the throwing paths easy to forget: `composePacks` throws ComposeError on a
|
|
128
|
+
* patch whose target does not exist or a duplicate record name, and
|
|
129
|
+
* `resolveLoadOrder` throws ResolveError on a missing dependency or a cycle. Both
|
|
130
|
+
* are reachable from `composeContentPacks` and both are caused by a MOD - so on
|
|
131
|
+
* the web host, which composes at module scope with no try, one mod's typo was a
|
|
132
|
+
* blank page. Not a bad message: no page, and therefore no mod manager to open
|
|
133
|
+
* and no way to turn the offending mod off again. The only exit was clearing
|
|
134
|
+
* localStorage.
|
|
135
|
+
*
|
|
136
|
+
* ONE BROKEN MOD COSTS THAT MOD. That is already the rule everywhere else here -
|
|
137
|
+
* a bad record file loses one contribution, a plugin that throws at import loses
|
|
138
|
+
* one plugin, a register() that throws loses one mod - and it is what this
|
|
139
|
+
* restores for the throwing paths. Each thrown message names its pack
|
|
140
|
+
* (`<pid>/<file>: ...` from compose.ts, `pack <id> requires ...` from resolve.ts),
|
|
141
|
+
* so the offender is identified, removed, and composition retried.
|
|
142
|
+
*
|
|
143
|
+
* `packs[0]` is the BASE GAME and is never dropped: if it is the pack named, or if
|
|
144
|
+
* no pack can be identified from the message, everything but the base is dropped
|
|
145
|
+
* at once. A game with no content cannot start, so that is the floor - and it is
|
|
146
|
+
* the outcome a player recognises ("my mods are off") rather than a dead tab.
|
|
147
|
+
*
|
|
148
|
+
* The loop is bounded by the pack count: every pass either returns or removes one
|
|
149
|
+
* pack, so it cannot spin.
|
|
150
|
+
*/
|
|
151
|
+
export declare function composeDroppingBroken(packs: readonly LoadedPack[]): {
|
|
152
|
+
readonly composed: ComposedContent;
|
|
153
|
+
readonly dropped: readonly DroppedPack[];
|
|
154
|
+
};
|
|
83
155
|
//# sourceMappingURL=loader.d.ts.map
|
package/dist/loader.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAIlD,OAAO,KAAK,EAAE,gBAAgB,EAA2B,MAAM,cAAc,CAAC;AAI9E;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,YAAY,CAAC;IACvB,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CACzC;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,4CAA4C;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;CACb;AAED,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B,yCAAyC;IACzC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACnC,uEAAuE;IACvE,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,iFAAiF;IACjF,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB;;;;OAIG;IACH,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB;AAyOD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,UAAU,EAAE,GAC3B,eAAe,CA6FjB;AAED,4DAA4D;AAC5D,MAAM,WAAW,WAAW;IAC1B,qBAAqB;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG;IACnE,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;CAC1C,CAgCA"}
|