@rpgm-tools/neo-angband-mod-sdk 0.10.0 → 0.13.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.
@@ -0,0 +1,342 @@
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. Importing a compiled copy would tie the build step to whether the front end
314
+ * happens to have been built.
315
+ *
316
+ * A HAND-WRITTEN MIRROR DRIFTS, and this one did. When ModPlugin grew `controller`,
317
+ * the host learned about it and this did not - so the Borg, whose only member is a
318
+ * controller, built fine in the host's eyes and was refused here as "would do
319
+ * nothing". The two lists are now cross-checked against each other by
320
+ * plugin-abi-agreement.test.ts, which reads both files: the duplication is allowed
321
+ * to exist, but not to disagree.
322
+ */
323
+ function pluginProblem(plugin) {
324
+ if (plugin === null || plugin === undefined) return "plugin.js has no default export";
325
+ if (typeof plugin !== "object" && typeof plugin !== "function") {
326
+ return `plugin.js default-exports a ${typeof plugin}, not a plugin object`;
327
+ }
328
+ if (!Number.isInteger(plugin.api)) return 'plugin.js declares no integer "api" version';
329
+ if (
330
+ plugin.hooks === undefined &&
331
+ plugin.register === undefined &&
332
+ plugin.controller === undefined
333
+ ) {
334
+ return "plugin.js declares no hooks, register or controller, so it would do nothing";
335
+ }
336
+ for (const name of ["hooks", "register", "controller", "uninstall"]) {
337
+ if (plugin[name] !== undefined && typeof plugin[name] !== "function") {
338
+ return `plugin.js: ${name} is not a function`;
339
+ }
340
+ }
341
+ return null;
342
+ }
@@ -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";
@@ -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;AACrD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC/D,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"}
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;AACrD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAElD,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"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;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,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;CACpB;AAoND;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,UAAU,EAAE,GAC3B,eAAe,CA0FjB"}
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"}