@rpgm-tools/neo-angband-mod-sdk 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE.md +43 -0
  2. package/README.md +72 -0
  3. package/dist/capabilities.d.ts +116 -0
  4. package/dist/capabilities.d.ts.map +1 -0
  5. package/dist/capabilities.js +170 -0
  6. package/dist/capabilities.js.map +1 -0
  7. package/dist/compose.d.ts +71 -0
  8. package/dist/compose.d.ts.map +1 -0
  9. package/dist/compose.js +118 -0
  10. package/dist/compose.js.map +1 -0
  11. package/dist/conflicts.d.ts +78 -0
  12. package/dist/conflicts.d.ts.map +1 -0
  13. package/dist/conflicts.js +160 -0
  14. package/dist/conflicts.js.map +1 -0
  15. package/dist/index.d.ts +31 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +24 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/loader.d.ts +83 -0
  20. package/dist/loader.d.ts.map +1 -0
  21. package/dist/loader.js +314 -0
  22. package/dist/loader.js.map +1 -0
  23. package/dist/manifest.d.ts +261 -0
  24. package/dist/manifest.d.ts.map +1 -0
  25. package/dist/manifest.js +264 -0
  26. package/dist/manifest.js.map +1 -0
  27. package/dist/patch.d.ts +90 -0
  28. package/dist/patch.d.ts.map +1 -0
  29. package/dist/patch.js +195 -0
  30. package/dist/patch.js.map +1 -0
  31. package/dist/record-key.d.ts +99 -0
  32. package/dist/record-key.d.ts.map +1 -0
  33. package/dist/record-key.js +157 -0
  34. package/dist/record-key.js.map +1 -0
  35. package/dist/resolve.d.ts +42 -0
  36. package/dist/resolve.d.ts.map +1 -0
  37. package/dist/resolve.js +161 -0
  38. package/dist/resolve.js.map +1 -0
  39. package/dist/semver.d.ts +37 -0
  40. package/dist/semver.d.ts.map +1 -0
  41. package/dist/semver.js +212 -0
  42. package/dist/semver.js.map +1 -0
  43. package/package.json +58 -0
  44. package/src/capabilities.ts +205 -0
  45. package/src/compose.ts +186 -0
  46. package/src/conflicts.ts +242 -0
  47. package/src/index.ts +73 -0
  48. package/src/loader.ts +393 -0
  49. package/src/manifest.ts +523 -0
  50. package/src/patch.ts +257 -0
  51. package/src/record-key.ts +180 -0
  52. package/src/resolve.ts +175 -0
  53. package/src/semver.ts +231 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Pack manifests: identity, versioning, and dependencies.
3
+ *
4
+ * Every pack - the base game included - carries a manifest. Load order,
5
+ * record composition, and savefile provenance all key off it.
6
+ */
7
+
8
+ /** Pack identifiers are namespaced: "<pack>:<id>", e.g. "core:kobold". */
9
+ export type PackRef = `${string}:${string}`;
10
+
11
+ /** The three pack shapes (docs/MODS.md). */
12
+ export type PackShape = "content" | "tiles" | "plugin";
13
+
14
+ /** Every shape, for validation and for iterating the facet vocabulary. */
15
+ export const PACK_SHAPES: readonly PackShape[] = ["content", "tiles", "plugin"];
16
+
17
+ /**
18
+ * WHAT A PACK CONTRIBUTES, as a SET rather than one exclusive kind.
19
+ *
20
+ * `shape` was exclusive, and the two halves of the loader gated on opposite
21
+ * values: code loaded only for `shape: "plugin"` (web/src/mod-code.ts) and
22
+ * records composed only for `shape: "content"` (web/src/pack.ts). So the folder
23
+ * layout the plugin documentation promises -
24
+ *
25
+ * my-mod/ manifest.json plugin.js monster.json tiles/orc.png
26
+ *
27
+ * - could never work: declaring "plugin" dropped monster.json from composition,
28
+ * and declaring "content" refused the code. Each half had tests and each half
29
+ * passed; nothing asserted the two together. A mod that adds a monster AND gives
30
+ * it behaviour is the ordinary case, not an exotic one.
31
+ *
32
+ * `facets` is that set. `shape` stays REQUIRED and remains the pack's primary
33
+ * kind - it is what the manager displays and what every existing manifest
34
+ * already carries - and when `facets` is present it must CONTAIN `shape`, so the
35
+ * two fields cannot contradict each other. A hybrid declares:
36
+ *
37
+ * { "shape": "content", "facets": ["content", "plugin"] }
38
+ *
39
+ * The consent property is unchanged and is why `facets` is a declaration rather
40
+ * than something inferred from the folder's contents: shipping plugin.js without
41
+ * naming the `plugin` facet is still a REFUSAL, because running code must be
42
+ * something a mod states rather than something a file listing implies.
43
+ */
44
+ export function packFacets(
45
+ manifest: Pick<PackManifest, "shape" | "facets">,
46
+ ): ReadonlySet<PackShape> {
47
+ return new Set(manifest.facets ?? [manifest.shape]);
48
+ }
49
+
50
+ /** Whether a pack contributes `facet` (its shape, or any declared facet). */
51
+ export function hasFacet(
52
+ manifest: Pick<PackManifest, "shape" | "facets">,
53
+ facet: PackShape,
54
+ ): boolean {
55
+ return packFacets(manifest).has(facet);
56
+ }
57
+
58
+ /**
59
+ * One player-toggleable "rule" a pack contributes: a flag name the pack owns,
60
+ * plus the human-facing label / description / default the in-app "Fixes &
61
+ * tweaks" menu renders.
62
+ *
63
+ * WHAT THIS USED TO BE, AND WHY IT IS NOT THAT ANY MORE. The first design made
64
+ * this a registry of CORE flags: the corrected behaviour lived in ported core as
65
+ * an off-by-default branch guarded by `if (modRuleEnabled(state, flag))`, the
66
+ * host applied the resolved choices to GameState.modRules, and no mod code ran.
67
+ * That design was deleted on 2026-07-29 because a flag-gated fix is not excluded
68
+ * from core - core shipped the fix body AND the mod's flag name, so deleting the
69
+ * mod folder would not have deleted a line of it. `modRuleEnabled` is GONE
70
+ * (packages/core/src/game/context.ts, where its removal is recorded), and
71
+ * `GameState.modRules` still exists but is OPAQUE to core: core stores it because
72
+ * a save has to record which patches a character was played with, and never
73
+ * branches on it (`context.ts`, the modRules doc comment).
74
+ *
75
+ * WHAT A RULE IS NOW: an input to the MOD's own code. Mods do run code. A mod
76
+ * that changes behaviour ships `hooks.ts` next to its manifest, default-exporting
77
+ * `(flags: Readonly<Record<string, boolean>>) => ModHooks`. The host discovers it
78
+ * (packages/web/src/mod-hooks.ts), calls it once per ENABLED mod in load order
79
+ * with only THAT mod's resolved flags (`choices[flag] ?? rule.default` for the
80
+ * rules its own manifest declares, so one mod cannot read another's toggles), and
81
+ * folds the results into the single ModHooks core holds via `composeModHooks`
82
+ * (packages/core/src/mod/hooks.ts). Each fix body lives in its mod's folder; what
83
+ * core contains is the generic seam, not any mod's name.
84
+ *
85
+ * A disabled mod's patches DO NOT EXIST rather than existing and reading false:
86
+ * its entry point is never called, it contributes no hook, composeModHooks
87
+ * returns undefined, and GameState.modHooks stays absent - so core runs the
88
+ * faithful 4.2.6 path, which is the only path compiled into the branch.
89
+ *
90
+ * A rules-only pack is still a plain `content` pack requesting no capabilities;
91
+ * `rules` remains pure declaration, and this manifest still holds no behaviour.
92
+ */
93
+ export interface PackRule {
94
+ /**
95
+ * The flag this rule toggles (e.g. "qol.autoDig"). Namespaced by convention to
96
+ * the owning pack, because the pack's own hooks.ts is what reads it; the host
97
+ * also records the resolved value on GameState.modRules as save state.
98
+ */
99
+ flag: string;
100
+ /** Short menu label (e.g. "Auto-dig"). */
101
+ title: string;
102
+ /** One- or two-line description shown under the toggle in the menu. */
103
+ description: string;
104
+ /** Whether the rule is ON by default when the mod is enabled. */
105
+ default: boolean;
106
+ }
107
+
108
+ /**
109
+ * A capability a scripted plugin requests (MOD_LIFECYCLE section 4). The
110
+ * runtime grants only what a `shape: plugin` pack declares and the user
111
+ * approves; content and tile packs request none. The vocabulary
112
+ * ("command:add", "event:turn-start", "state:*.read", "network:<host>", ...)
113
+ * is enforced by the capability model (P7 phase 5); the manifest only records
114
+ * the request, so any string is accepted here.
115
+ */
116
+ export type Capability = string;
117
+
118
+ /**
119
+ * One graphics mode a `tiles`-facet pack contributes.
120
+ *
121
+ * This was read loosely off the raw JSON for a long time and was NOT in the
122
+ * validated schema, which the moddability measurement recorded as a gap
123
+ * (docs/modding/MOD_REACH.md). The consequence was specific rather than
124
+ * theoretical: a typo in `grafID` or `path` produced no error anywhere - the entry
125
+ * was silently skipped, and a mod author saw a Graphics row that simply never
126
+ * appeared. Declaring it here means the manifest is refused at the edge, with the
127
+ * mod's id and the offending field named.
128
+ */
129
+ export interface PackTilePack {
130
+ /**
131
+ * The list.txt serial number this mode renders as. A `tilesheet` pack must claim
132
+ * one the core catalog already knows (it borrows that row's cell size, atlas
133
+ * filename and pref file); a `linoleum` pack carries its own metadata and may
134
+ * claim a new id - use >= 100 to stay clear of upstream's numbering.
135
+ */
136
+ grafID: number;
137
+ /**
138
+ * The pack's directory INSIDE THE MOD FOLDER (`original-tiles`,
139
+ * `tiles/my-set`), or absent for a pack that is the mod folder itself.
140
+ *
141
+ * Mod-relative, not a site path. A mod cannot know where a host serves it from,
142
+ * and two of the three sources serve it from nowhere: a folder the player picked
143
+ * has no URL for its files until their bytes are wrapped in a blob:, and a mod
144
+ * installed from a repository lives in IndexedDB. The host composes this with
145
+ * the mod's own asset resolver.
146
+ */
147
+ path?: string;
148
+ /**
149
+ * Which renderer draws it: `tilesheet` (or absent) for upstream's own scheme -
150
+ * one atlas PNG addressed by row/column - and `linoleum` for a loose pack, a
151
+ * directory of individually named PNGs. This is the PACK's renderer; the
152
+ * manifest's top-level `engine` is the game version the mod targets.
153
+ */
154
+ engine?: "tilesheet" | "linoleum";
155
+ /**
156
+ * The Graphics row's label. Required in effect for a mode the core catalog does
157
+ * not have, since there would be nothing to name the row; a pack re-skinning a
158
+ * catalogued mode may omit it and borrow that row's name.
159
+ */
160
+ menuname?: string;
161
+ }
162
+
163
+ export interface PackManifest {
164
+ /**
165
+ * The pack's namespace: lowercase kebab-case, unique among loaded
166
+ * packs. "core" is reserved for the base game.
167
+ */
168
+ id: string;
169
+ /** Human-readable title. */
170
+ name: string;
171
+ /** Semantic version of the pack itself. */
172
+ version: string;
173
+ /**
174
+ * The pack's primary kind, and what the mod manager displays. When `facets` is
175
+ * absent this is the pack's only facet.
176
+ */
177
+ shape: PackShape;
178
+ /**
179
+ * Everything this pack contributes, when it contributes more than one kind -
180
+ * a mod shipping both `plugin.js` and record JSON declares
181
+ * `["content", "plugin"]`. Must contain `shape`. See packFacets().
182
+ */
183
+ facets?: readonly PackShape[];
184
+ /**
185
+ * Engine version range the pack requires (semver range, e.g. ">=0.5.0
186
+ * <0.7.0"). A save refuses to load on an incompatible engine.
187
+ */
188
+ engine?: string;
189
+ /**
190
+ * Packs this one depends on, by id. A pack may only patch, replace,
191
+ * or remove records owned by packs it declares here. Values are
192
+ * version constraints; "*" accepts any version.
193
+ */
194
+ dependencies?: Record<string, string>;
195
+ /**
196
+ * Soft dependencies: if the named pack is present it loads first and may
197
+ * be modified, but its absence is not an error (MOD_LIFECYCLE section 2).
198
+ */
199
+ optionalDependencies?: Record<string, string>;
200
+ /** Load-order hints (MOD_LIFECYCLE section 3): follow / precede these ids. */
201
+ loadAfter?: string[];
202
+ loadBefore?: string[];
203
+ /**
204
+ * The pack's own save-block schema version. The engine hands a mod its
205
+ * old `mod:<id>` bag and asks it to migrate from this number on update.
206
+ */
207
+ saveSchema?: number;
208
+ /** Capabilities a `shape: plugin` pack requests (see Capability). */
209
+ capabilities?: Capability[];
210
+ /**
211
+ * The mod-plugin ABI version this pack's `plugin.js` was written against, and
212
+ * REQUIRED of any pack that ships one. Separate from `engine` on purpose: the
213
+ * engine version and the ABI a mod's code compiles against diverge immediately
214
+ * - a patch release changes the former and not the latter.
215
+ *
216
+ * Declared here, in the MANIFEST, rather than only inside plugin.js, so the
217
+ * host can refuse an incompatible plugin BEFORE importing it. A version check
218
+ * that lives inside the module can only run after the module's top-level code
219
+ * has already executed, which is the wrong order for player-supplied code.
220
+ *
221
+ * An exact integer, matched exactly, because the ABI is explicitly unstable
222
+ * until 1.0: every change to it bumps this number and every mod must
223
+ * republish. A semver range would imply a compatibility promise that does not
224
+ * exist yet.
225
+ */
226
+ modApi?: number;
227
+ /**
228
+ * Player-toggleable flags this pack owns (see PackRule). The bundled qol /
229
+ * bug-fixes mods use this to declare their fixes/tweaks for the in-app "Fixes
230
+ * & tweaks" menu; the host resolves (choice ?? default), hands each mod its own
231
+ * slice when it calls that mod's hooks.ts, and records the result on
232
+ * GameState.modRules as save state. Absent for a pack with nothing to toggle.
233
+ */
234
+ rules?: PackRule[];
235
+ /**
236
+ * Graphics modes this pack contributes (see PackTilePack). Only read for a pack
237
+ * with the `tiles` facet; a content pack that declares them contributes none.
238
+ */
239
+ tilePacks?: PackTilePack[];
240
+ /**
241
+ * Declares the pack deliberately nondeterministic (a wall-clock event, an
242
+ * external agent, live multiplayer). Trips the save's determinism ratchet
243
+ * once, irreversibly (MOD_LIFECYCLE section 4, decision 4/18).
244
+ */
245
+ nondeterministic?: boolean;
246
+ /** Declares a gameplay change that permanently makes an enabled save non-scoring. */
247
+ affectsGameplay?: boolean;
248
+ /**
249
+ * What the pack does, in the author's own words, for a human deciding whether
250
+ * to enable it. Prose, not a tagline: the in-app mod manager wraps it to fill
251
+ * the detail pane of the highlighted row, and a marketplace listing would show
252
+ * the same text. Absent is allowed but leaves a player with only the id/shape
253
+ * to go on.
254
+ */
255
+ description?: string;
256
+ /** Free-form author credit. */
257
+ author?: string;
258
+ /** SPDX license expression for the pack's own content. */
259
+ license?: string;
260
+ /** Source repository URL (installer provenance). */
261
+ repository?: string;
262
+ /** Path to the changelog within the pack. */
263
+ changelog?: string;
264
+ /** Paths to screenshot assets within the pack (marketplace preview). */
265
+ screenshots?: string[];
266
+ }
267
+
268
+ const ID_RE = /^[a-z][a-z0-9-]*$/;
269
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
270
+
271
+ export class ManifestError extends Error {}
272
+
273
+ /** Validate a parsed manifest object; throws ManifestError. */
274
+ export function validateManifest(value: unknown): PackManifest {
275
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
276
+ throw new ManifestError("manifest must be an object");
277
+ }
278
+ const m = value as Record<string, unknown>;
279
+ if (typeof m["id"] !== "string" || !ID_RE.test(m["id"])) {
280
+ throw new ManifestError(
281
+ `manifest id must be lowercase kebab-case: ${String(m["id"])}`,
282
+ );
283
+ }
284
+ if (typeof m["name"] !== "string" || m["name"].length === 0) {
285
+ throw new ManifestError(`manifest ${m["id"]}: name is required`);
286
+ }
287
+ if (typeof m["version"] !== "string" || !VERSION_RE.test(m["version"])) {
288
+ throw new ManifestError(
289
+ `manifest ${m["id"]}: version must be semver, got ${String(m["version"])}`,
290
+ );
291
+ }
292
+ if (!PACK_SHAPES.includes(m["shape"] as PackShape)) {
293
+ throw new ManifestError(
294
+ `manifest ${m["id"]}: shape must be one of ${PACK_SHAPES.join(", ")}`,
295
+ );
296
+ }
297
+ const id = m["id"] as string;
298
+ validateFacets(m["facets"], id, m["shape"] as PackShape);
299
+ validateDepMap(m["dependencies"], id, "dependencies");
300
+ validateDepMap(m["optionalDependencies"], id, "optionalDependencies");
301
+ validateIdList(m["loadAfter"], id, "loadAfter");
302
+ validateIdList(m["loadBefore"], id, "loadBefore");
303
+ if (m["saveSchema"] !== undefined) {
304
+ const s = m["saveSchema"];
305
+ if (typeof s !== "number" || !Number.isInteger(s) || s < 0) {
306
+ throw new ManifestError(
307
+ `manifest ${id}: saveSchema must be a non-negative integer`,
308
+ );
309
+ }
310
+ }
311
+ if (m["capabilities"] !== undefined) {
312
+ if (
313
+ !Array.isArray(m["capabilities"]) ||
314
+ m["capabilities"].some((c) => typeof c !== "string")
315
+ ) {
316
+ throw new ManifestError(`manifest ${id}: capabilities must be strings`);
317
+ }
318
+ }
319
+ if (m["modApi"] !== undefined) {
320
+ const a = m["modApi"];
321
+ if (typeof a !== "number" || !Number.isInteger(a) || a < 1) {
322
+ throw new ManifestError(
323
+ `manifest ${id}: modApi must be a positive integer (the mod-plugin ABI version)`,
324
+ );
325
+ }
326
+ }
327
+ if (
328
+ m["nondeterministic"] !== undefined &&
329
+ typeof m["nondeterministic"] !== "boolean"
330
+ ) {
331
+ throw new ManifestError(`manifest ${id}: nondeterministic must be a boolean`);
332
+ }
333
+ if (
334
+ m["affectsGameplay"] !== undefined &&
335
+ typeof m["affectsGameplay"] !== "boolean"
336
+ ) {
337
+ throw new ManifestError(`manifest ${id}: affectsGameplay must be a boolean`);
338
+ }
339
+ validateRules(m["rules"], id);
340
+ validateTilePacks(m["tilePacks"], id);
341
+ for (const key of [
342
+ "engine",
343
+ "repository",
344
+ "changelog",
345
+ "description",
346
+ "author",
347
+ "license",
348
+ ] as const) {
349
+ if (m[key] !== undefined && typeof m[key] !== "string") {
350
+ throw new ManifestError(`manifest ${id}: ${key} must be a string`);
351
+ }
352
+ }
353
+ return m as unknown as PackManifest;
354
+ }
355
+
356
+ /** Validate an optional id->constraint map field (dependencies-shaped). */
357
+ function validateDepMap(deps: unknown, id: string, field: string): void {
358
+ if (deps === undefined) return;
359
+ if (typeof deps !== "object" || deps === null || Array.isArray(deps)) {
360
+ throw new ManifestError(`manifest ${id}: ${field} must be a map`);
361
+ }
362
+ for (const [dep, constraint] of Object.entries(deps)) {
363
+ if (!ID_RE.test(dep)) {
364
+ throw new ManifestError(`manifest ${id}: bad ${field} id ${dep}`);
365
+ }
366
+ if (typeof constraint !== "string") {
367
+ throw new ManifestError(
368
+ `manifest ${id}: ${field} ${dep} constraint must be a string`,
369
+ );
370
+ }
371
+ }
372
+ }
373
+
374
+ /** Validate the optional `rules` array (PackRule[]); throws ManifestError. */
375
+ function validateRules(value: unknown, id: string): void {
376
+ if (value === undefined) return;
377
+ if (!Array.isArray(value)) {
378
+ throw new ManifestError(`manifest ${id}: rules must be an array`);
379
+ }
380
+ const seen = new Set<string>();
381
+ for (const entry of value) {
382
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
383
+ throw new ManifestError(`manifest ${id}: each rule must be an object`);
384
+ }
385
+ const r = entry as Record<string, unknown>;
386
+ if (typeof r["flag"] !== "string" || r["flag"].length === 0) {
387
+ throw new ManifestError(`manifest ${id}: rule flag must be a non-empty string`);
388
+ }
389
+ if (seen.has(r["flag"])) {
390
+ throw new ManifestError(`manifest ${id}: duplicate rule flag ${r["flag"]}`);
391
+ }
392
+ seen.add(r["flag"]);
393
+ if (typeof r["title"] !== "string" || r["title"].length === 0) {
394
+ throw new ManifestError(`manifest ${id}: rule ${r["flag"]} title must be a non-empty string`);
395
+ }
396
+ if (typeof r["description"] !== "string") {
397
+ throw new ManifestError(`manifest ${id}: rule ${r["flag"]} description must be a string`);
398
+ }
399
+ if (typeof r["default"] !== "boolean") {
400
+ throw new ManifestError(`manifest ${id}: rule ${r["flag"]} default must be a boolean`);
401
+ }
402
+ }
403
+ }
404
+
405
+ /** The pack renderers a tilePacks entry may name. */
406
+ const TILE_ENGINES: readonly string[] = ["tilesheet", "linoleum"];
407
+
408
+ /**
409
+ * Validate the optional `tilePacks` array (PackTilePack[]); throws ManifestError.
410
+ *
411
+ * `path` is checked for being MOD-RELATIVE, and that check is the point rather than
412
+ * tidiness. It used to be a site-root-relative URL base, which only a bundled mod
413
+ * could ever get right; a manifest carrying the old form would resolve to
414
+ * `mods/<id>/mods/<id>/…` and 404 into ASCII with nothing said. An absolute path, a
415
+ * scheme, or a `..` escape is refused for the same reason a pack's code files are
416
+ * read by pack-relative path: the host decides where a mod's bytes live.
417
+ */
418
+ function validateTilePacks(value: unknown, id: string): void {
419
+ if (value === undefined) return;
420
+ if (!Array.isArray(value)) {
421
+ throw new ManifestError(`manifest ${id}: tilePacks must be an array`);
422
+ }
423
+ for (const entry of value) {
424
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
425
+ throw new ManifestError(`manifest ${id}: each tilePacks entry must be an object`);
426
+ }
427
+ const p = entry as Record<string, unknown>;
428
+ const graf = p["grafID"];
429
+ if (typeof graf !== "number" || !Number.isInteger(graf) || graf < 0) {
430
+ throw new ManifestError(
431
+ `manifest ${id}: tilePacks grafID must be a non-negative integer, got ${String(graf)}`,
432
+ );
433
+ }
434
+ if (p["engine"] !== undefined && !TILE_ENGINES.includes(p["engine"] as string)) {
435
+ throw new ManifestError(
436
+ `manifest ${id}: tilePacks engine must be one of ${TILE_ENGINES.join(", ")}, got ${String(p["engine"])}`,
437
+ );
438
+ }
439
+ if (p["menuname"] !== undefined && typeof p["menuname"] !== "string") {
440
+ throw new ManifestError(`manifest ${id}: tilePacks menuname must be a string`);
441
+ }
442
+ if (p["path"] === undefined) continue;
443
+ const path = p["path"];
444
+ if (typeof path !== "string") {
445
+ throw new ManifestError(`manifest ${id}: tilePacks path must be a string`);
446
+ }
447
+ if (/^([a-z][a-z0-9+.-]*:)?\//iu.test(path) || path.startsWith("\\")) {
448
+ throw new ManifestError(
449
+ `manifest ${id}: tilePacks path "${path}" must be relative to the mod folder, not a site or absolute path`,
450
+ );
451
+ }
452
+ if (path.split(/[/\\]/u).includes("..")) {
453
+ throw new ManifestError(
454
+ `manifest ${id}: tilePacks path "${path}" must stay inside the mod folder`,
455
+ );
456
+ }
457
+ }
458
+ }
459
+
460
+ /** Validate an optional array-of-pack-ids field (loadAfter/loadBefore). */
461
+ function validateIdList(value: unknown, id: string, field: string): void {
462
+ if (value === undefined) return;
463
+ if (!Array.isArray(value)) {
464
+ throw new ManifestError(`manifest ${id}: ${field} must be an array`);
465
+ }
466
+ for (const entry of value) {
467
+ if (typeof entry !== "string" || !ID_RE.test(entry)) {
468
+ throw new ManifestError(`manifest ${id}: bad ${field} id ${String(entry)}`);
469
+ }
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Validate the optional `facets` list against the pack's `shape`.
475
+ *
476
+ * `shape` must appear in `facets`. Without that rule the two fields could
477
+ * disagree - `{shape: "content", facets: ["plugin"]}` - and every consumer would
478
+ * have to decide which one it trusted, which is how the exclusive `shape`
479
+ * produced a folder layout the documentation promised and the loader refused.
480
+ * One source of truth, checked once, at the edge.
481
+ */
482
+ function validateFacets(value: unknown, id: string, shape: PackShape): void {
483
+ if (value === undefined) return;
484
+ if (!Array.isArray(value) || value.length === 0) {
485
+ throw new ManifestError(
486
+ `manifest ${id}: facets must be a non-empty array of ${PACK_SHAPES.join(", ")}`,
487
+ );
488
+ }
489
+ const seen = new Set<string>();
490
+ for (const entry of value) {
491
+ if (typeof entry !== "string" || !PACK_SHAPES.includes(entry as PackShape)) {
492
+ throw new ManifestError(
493
+ `manifest ${id}: facet must be one of ${PACK_SHAPES.join(", ")}, got ${String(entry)}`,
494
+ );
495
+ }
496
+ if (seen.has(entry)) {
497
+ throw new ManifestError(`manifest ${id}: facet "${entry}" is listed twice`);
498
+ }
499
+ seen.add(entry);
500
+ }
501
+ if (!seen.has(shape)) {
502
+ throw new ManifestError(
503
+ `manifest ${id}: facets ${JSON.stringify(value)} must include its shape "${shape}"`,
504
+ );
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Slug a record name into the id segment of a PackRef: lowercase, runs
510
+ * of non-alphanumerics collapse to single hyphens ("Farmer Maggot" ->
511
+ * "farmer-maggot"). Stable: this is a savefile-visible identity.
512
+ */
513
+ export function slugify(name: string): string {
514
+ return name
515
+ .toLowerCase()
516
+ .replace(/[^a-z0-9]+/g, "-")
517
+ .replace(/^-+|-+$/g, "");
518
+ }
519
+
520
+ /** Build a namespaced record reference. */
521
+ export function packRef(packId: string, name: string): PackRef {
522
+ return `${packId}:${slugify(name)}`;
523
+ }