@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
package/src/patch.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Field-level patch composition (MOD_LIFECYCLE section 3, P7 phase 3).
3
+ *
4
+ * The coarse `patches` path in compose.ts deep-merges whole record bodies:
5
+ * simple, but two mods touching one record always look like they collide even
6
+ * when they change unrelated fields. The field-level model fixes that. A patch
7
+ * is an ordered list of field operations - `set`, `merge`, `addFlag`,
8
+ * `removeFlag`, `add`, `mul` - each addressing a dot-path into the record.
9
+ * Patches from different packs apply in load order; two packs that touch
10
+ * DIFFERENT fields compose with zero conflict, and only a genuine same-field
11
+ * collision is reported (then load order decides who wins, and the app says
12
+ * so). This is the finer lever the ratified design calls for, and it is the
13
+ * data the conflict report (phase 6) reads.
14
+ *
15
+ * Pure and deterministic: given a base record and an ordered patch list the
16
+ * output and the conflict set are fully determined.
17
+ */
18
+
19
+ import type { JsonRecord, JsonValue } from "./compose.js";
20
+ import { mergePatch } from "./compose.js";
21
+
22
+ export class PatchError extends Error {}
23
+
24
+ /** One field operation, addressing `path` (a dot-path into the record). */
25
+ export type FieldOp =
26
+ /** Replace the value at path outright. */
27
+ | { op: "set"; path: string; value: JsonValue }
28
+ /** Deep-merge an object value into the object at path (compose.mergePatch). */
29
+ | { op: "merge"; path: string; value: JsonRecord }
30
+ /** Ensure `flag` is present in the string array at path (set union). */
31
+ | { op: "addFlag"; path: string; flag: string }
32
+ /** Remove `flag` from the string array at path, if present. */
33
+ | { op: "removeFlag"; path: string; flag: string }
34
+ /** Add a number to the numeric value at path (missing = 0). */
35
+ | { op: "add"; path: string; value: number }
36
+ /** Multiply the numeric value at path (missing = 0). */
37
+ | { op: "mul"; path: string; value: number };
38
+
39
+ /** An ordered list of field operations - one pack's patch of one record. */
40
+ export type FieldPatch = FieldOp[];
41
+
42
+ /** The flag ops compose as set operations; the rest are order-dependent. */
43
+ function isCommutative(op: FieldOp["op"]): boolean {
44
+ return op === "addFlag" || op === "removeFlag";
45
+ }
46
+
47
+ /* ------------------------------------------------------------------ *
48
+ * Dot-path access.
49
+ *
50
+ * A path segment that is a run of digits indexes an ARRAY ("level-max.0.value").
51
+ * Array traversal is not a convenience: a great deal of upstream gamedata is
52
+ * label/value lists rather than objects - every section of constants.json, a
53
+ * store's owner list, a body's slot list, the visuals flicker table - so without
54
+ * it a fieldPatch into those files could not address anything. Worse, the first
55
+ * version of setPath treated an existing array as an unusable intermediate and
56
+ * REPLACED it with a fresh object, which turned `set level-max.0.value` into
57
+ * silent destruction of the whole list. Objects still win when the container is
58
+ * an object, so a literal "0" key is unaffected.
59
+ * ------------------------------------------------------------------ */
60
+
61
+ /** A path segment as an array index, or null when it is a plain object key. */
62
+ function arrayIndex(part: string): number | null {
63
+ return /^(?:0|[1-9][0-9]*)$/.test(part) ? Number(part) : null;
64
+ }
65
+
66
+ /** One step down a path, through either an object key or an array index. */
67
+ function childOf(cur: JsonValue | undefined, part: string): JsonValue | undefined {
68
+ if (Array.isArray(cur)) {
69
+ const at = arrayIndex(part);
70
+ return at === null ? undefined : cur[at];
71
+ }
72
+ if (typeof cur === "object" && cur !== null) return (cur as JsonRecord)[part];
73
+ return undefined;
74
+ }
75
+
76
+ function getPath(record: JsonRecord, path: string): JsonValue | undefined {
77
+ let cur: JsonValue | undefined = record;
78
+ for (const part of path.split(".")) {
79
+ cur = childOf(cur, part);
80
+ if (cur === undefined) return undefined;
81
+ }
82
+ return cur;
83
+ }
84
+
85
+ /** Write one slot of an object or an array container. */
86
+ function assignAt(
87
+ container: JsonRecord | JsonValue[],
88
+ part: string,
89
+ value: JsonValue,
90
+ ): void {
91
+ if (Array.isArray(container)) {
92
+ const at = arrayIndex(part);
93
+ if (at === null) {
94
+ throw new PatchError(
95
+ `patch: "${part}" is not an array index, and the value at that point is an array`,
96
+ );
97
+ }
98
+ container[at] = value;
99
+ return;
100
+ }
101
+ container[part] = value;
102
+ }
103
+
104
+ /**
105
+ * Set a value at a dot-path, creating intermediate containers as needed. A
106
+ * created container is an array when the next segment is an index and an object
107
+ * otherwise, so `set a.0.b` on a record with no `a` builds `{a:[{b:...}]}`.
108
+ */
109
+ function setPath(record: JsonRecord, path: string, value: JsonValue): void {
110
+ const parts = path.split(".");
111
+ let cur: JsonRecord | JsonValue[] = record;
112
+ for (let i = 0; i < parts.length - 1; i++) {
113
+ const part = parts[i] as string;
114
+ const next = childOf(cur as JsonValue, part);
115
+ if (typeof next !== "object" || next === null) {
116
+ const fresh: JsonValue =
117
+ arrayIndex(parts[i + 1] as string) === null ? {} : [];
118
+ assignAt(cur, part, fresh);
119
+ cur = fresh as JsonRecord | JsonValue[];
120
+ } else {
121
+ cur = next as JsonRecord | JsonValue[];
122
+ }
123
+ }
124
+ assignAt(cur, parts[parts.length - 1] as string, value);
125
+ }
126
+
127
+ /* ------------------------------------------------------------------ *
128
+ * Applying a single patch.
129
+ * ------------------------------------------------------------------ */
130
+
131
+ /** Apply one field patch to a record, returning a new record (pure). */
132
+ export function applyFieldPatch(record: JsonRecord, ops: FieldPatch): JsonRecord {
133
+ const out = structuredJsonClone(record);
134
+ for (const op of ops) applyOp(out, op);
135
+ return out;
136
+ }
137
+
138
+ function applyOp(record: JsonRecord, op: FieldOp): void {
139
+ switch (op.op) {
140
+ case "set":
141
+ setPath(record, op.path, op.value);
142
+ return;
143
+ case "merge": {
144
+ const cur = getPath(record, op.path);
145
+ const base =
146
+ typeof cur === "object" && cur !== null && !Array.isArray(cur)
147
+ ? (cur as JsonRecord)
148
+ : {};
149
+ setPath(record, op.path, mergePatch(base, op.value));
150
+ return;
151
+ }
152
+ case "addFlag": {
153
+ const list = asFlagList(getPath(record, op.path), op.path);
154
+ if (!list.includes(op.flag)) list.push(op.flag);
155
+ setPath(record, op.path, list);
156
+ return;
157
+ }
158
+ case "removeFlag": {
159
+ const list = asFlagList(getPath(record, op.path), op.path);
160
+ setPath(
161
+ record,
162
+ op.path,
163
+ list.filter((f) => f !== op.flag),
164
+ );
165
+ return;
166
+ }
167
+ case "add": {
168
+ const cur = getPath(record, op.path);
169
+ const n = typeof cur === "number" ? cur : 0;
170
+ setPath(record, op.path, n + op.value);
171
+ return;
172
+ }
173
+ case "mul": {
174
+ const cur = getPath(record, op.path);
175
+ const n = typeof cur === "number" ? cur : 0;
176
+ setPath(record, op.path, n * op.value);
177
+ return;
178
+ }
179
+ }
180
+ }
181
+
182
+ /** A flag field must be a string array (or absent, treated as empty). */
183
+ function asFlagList(value: JsonValue | undefined, path: string): string[] {
184
+ if (value === undefined) return [];
185
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
186
+ throw new PatchError(`patch: field ${path} is not a flag list (string[])`);
187
+ }
188
+ return [...(value as string[])];
189
+ }
190
+
191
+ /** A structural JSON clone (no Date.now/Math.random dependence). */
192
+ function structuredJsonClone(record: JsonRecord): JsonRecord {
193
+ return JSON.parse(JSON.stringify(record)) as JsonRecord;
194
+ }
195
+
196
+ /* ------------------------------------------------------------------ *
197
+ * Composing ordered patches with conflict detection.
198
+ * ------------------------------------------------------------------ */
199
+
200
+ /** One same-field collision between two or more packs. */
201
+ export interface FieldConflict {
202
+ /** The dot-path both packs wrote. */
203
+ path: string;
204
+ /** The packs that wrote it, in load order (last one wins the value). */
205
+ owners: string[];
206
+ }
207
+
208
+ /** The result of composing several packs' patches over one base record. */
209
+ export interface ComposedPatch {
210
+ /** The merged record (all patches applied in load order). */
211
+ value: JsonRecord;
212
+ /** Same-field collisions, empty when every pack touched distinct fields. */
213
+ conflicts: FieldConflict[];
214
+ }
215
+
216
+ /**
217
+ * Compose several packs' field patches over a base record, applying them in
218
+ * the given (load) order and reporting same-field collisions. A field is a
219
+ * conflict when two or more distinct packs write it with an order-dependent op
220
+ * (set / merge / add / mul); pure flag ops (addFlag / removeFlag) compose as
221
+ * set operations and never conflict on their own.
222
+ */
223
+ export function composeFieldPatches(
224
+ base: JsonRecord,
225
+ patches: ReadonlyArray<{ owner: string; ops: FieldPatch }>,
226
+ ): ComposedPatch {
227
+ let value = structuredJsonClone(base);
228
+ /* path -> the owners who wrote it, and whether any write was order-dependent. */
229
+ const writers = new Map<string, { owners: string[]; ordered: boolean }>();
230
+
231
+ for (const { owner, ops } of patches) {
232
+ value = applyFieldPatch(value, ops);
233
+ for (const op of ops) {
234
+ const entry = writers.get(op.path) ?? { owners: [], ordered: false };
235
+ if (entry.owners[entry.owners.length - 1] !== owner) {
236
+ entry.owners.push(owner);
237
+ }
238
+ if (!isCommutative(op.op)) entry.ordered = true;
239
+ writers.set(op.path, entry);
240
+ }
241
+ }
242
+
243
+ const conflicts: FieldConflict[] = [];
244
+ for (const [path, entry] of writers) {
245
+ if (entry.ordered && entry.owners.length > 1) {
246
+ conflicts.push({ path, owners: entry.owners });
247
+ }
248
+ }
249
+ return { value, conflicts };
250
+ }
251
+
252
+ /** The set of dot-paths a patch writes (for external conflict analysis). */
253
+ export function touchedFields(ops: FieldPatch): Set<string> {
254
+ const out = new Set<string>();
255
+ for (const op of ops) out.add(op.path);
256
+ return out;
257
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Per-record identity for the record files whose identity is NOT a unique
3
+ * string `name`.
4
+ *
5
+ * CURRENT STATE, FIRST, BECAUSE THIS COMMENT USED TO MISLEAD. Every record file
6
+ * but ONE is addressable per record today: 24 by a unique `name`, 19 by the
7
+ * explicit specs in this file, and `history` by nothing - and an op against
8
+ * `history` is REPORTED, never silently dropped. Two independent reviewers read
9
+ * the older wording, took the "the other 20 files do not fit it" paragraph below
10
+ * for the present tense, and filed the same non-existent P1 ("20 files silently
11
+ * discard patch/replace/remove, including object, ego_item, vault, trap, store,
12
+ * brand, slay, projection, constants"). Those nine are precisely the files this
13
+ * table fixes. If you are about to report that bug, run record-key.test.ts.
14
+ *
15
+ * WHY THIS EXISTS
16
+ *
17
+ * composePacks (compose.ts) keys every record by `packRef(pack, slugify(name))`.
18
+ * That is the right identity for the 24 upstream record files whose records
19
+ * carry a unique `name` - monster, object_property, terrain and so on - and it
20
+ * is what makes `patches` / `replaces` / `fieldPatches` / `removes` work there.
21
+ *
22
+ * The other 20 files did not fit it, for two measured reasons (counts taken over
23
+ * packages/content/pack on 2026-07-29). This is the problem statement, not the
24
+ * status:
25
+ *
26
+ * - 14 files have no string `name` at all. Their identity lives somewhere else:
27
+ * `code` (brand, slay, chest_trap, projection), the tval half of a composite
28
+ * (`object_base`), the upstream `name:<name>:<desc>` line (trap), an index
29
+ * (pain, names), a plain other field (store, ui_knowledge, body, world,
30
+ * flavor, hints), or nowhere at all because the file is a single config
31
+ * singleton (constants, visuals).
32
+ * - 6 files DO have `name` but core's own data slugs two records to the same
33
+ * ref, mostly because slugify drops `*` and `+`: object has 5 such pairs
34
+ * ("Acquirement" / "*Acquirement*"), vault 1 ("Little eruption" /
35
+ * "Little eruption+"), and brand / slay / chest_trap name several records
36
+ * after the element rather than the variant. ego_item is the genuine case:
37
+ * "of Acid" exists twice, distinguished only by which item types it applies
38
+ * to - which are the very fields a mod would patch, so they cannot be part of
39
+ * its identity.
40
+ *
41
+ * Before this file existed, a per-record op against any of those 20 was
42
+ * SILENTLY DROPPED (loader.ts stripped the whole contribution before compose saw
43
+ * it). This table is what makes 19 of them addressable, and it is deliberately
44
+ * an EXPLICIT declaration rather than a heuristic: guessing a key would trade a
45
+ * silent drop for a silent mis-merge, which is worse. Every entry below was
46
+ * verified unique over the shipped core pack (see record-key.test.ts, which
47
+ * reads the real pack and fails if a declared key stops being unique).
48
+ *
49
+ * `history` is deliberately absent: a history record is
50
+ * `{chart:{chart,next,roll}, phrase}` and every part of that is a value a mod
51
+ * would legitimately change, so it has no identity to key on. An op against it
52
+ * is REPORTED, not applied - see loader.ts.
53
+ *
54
+ * AMBIGUITY IS NAMED, NEVER GUESSED. A key that two records in the same file
55
+ * claim (object's 5 pairs, ego_item's 25) makes that one ref unaddressable; the
56
+ * records stay in the game and any op naming the ref becomes a reported problem.
57
+ * The rest of the file remains addressable per record.
58
+ */
59
+
60
+ import { slugify } from "./manifest.js";
61
+ import type { JsonRecord, JsonValue } from "./compose.js";
62
+
63
+ /**
64
+ * How to derive one record's identity within a file.
65
+ *
66
+ * - `fields`: slugify each dot-path in order and join with "--". Every path must
67
+ * resolve to a string or a number; anything else (absent, object, array) means
68
+ * the record has no derivable key and is simply not addressable.
69
+ * - `singleton`: the file holds exactly one config record, so the FILE is the
70
+ * identity (ref `<pack>:<file>`). A second record in such a file collides with
71
+ * the first and both become unaddressable, which is the correct answer - the
72
+ * host binds one.
73
+ */
74
+ export type RecordKeySpec =
75
+ | { readonly kind: "fields"; readonly paths: readonly string[] }
76
+ | { readonly kind: "singleton" };
77
+
78
+ /**
79
+ * The identity of every record file that is NOT keyed by a unique string `name`.
80
+ *
81
+ * A file absent from this table is keyed by `name`, which is what composePacks
82
+ * already does. Keys here are file stems, exactly as they appear in a pack
83
+ * folder (`brand.json` -> `brand`).
84
+ */
85
+ export const RECORD_KEY_SPECS: Readonly<Record<string, RecordKeySpec>> = {
86
+ /* `code` is the upstream identity; `name` names the element, so ACID_2 and
87
+ * ACID_3 both slug to "acid". */
88
+ brand: { kind: "fields", paths: ["code"] },
89
+ slay: { kind: "fields", paths: ["code"] },
90
+ chest_trap: { kind: "fields", paths: ["code"] },
91
+ /* projection: `code` is on all 56 records, `name` is not. */
92
+ projection: { kind: "fields", paths: ["code"] },
93
+ /* object_base: `name` is the composite {tval, name}; tval is the key upstream
94
+ * looks bases up by. */
95
+ object_base: { kind: "fields", paths: ["name.tval"] },
96
+ /* trap: `name` is the composite {name, desc} - upstream's `name:<name>:<desc>`
97
+ * line. The display half repeats (6 records are "strange rune"); the pair is
98
+ * unique. */
99
+ trap: { kind: "fields", paths: ["name.name", "name.desc"] },
100
+ /* store: the STORE_* code. */
101
+ store: { kind: "fields", paths: ["store"] },
102
+ /* pain: the message-set index, which IS its identity (mon_pain_msg). */
103
+ pain: { kind: "fields", paths: ["type"] },
104
+ ui_knowledge: { kind: "fields", paths: ["monster-category"] },
105
+ /* names: the random-name section index. */
106
+ names: { kind: "fields", paths: ["section"] },
107
+ body: { kind: "fields", paths: ["body"] },
108
+ /* world: the level's name, which is what upstream's up/down links reference. */
109
+ world: { kind: "fields", paths: ["level.name"] },
110
+ /* flavor: one record per object base, keyed by that base's tval. */
111
+ flavor: { kind: "fields", paths: ["kind.tval"] },
112
+ /* hints: the hint text is the whole record, so it is also its identity. */
113
+ hints: { kind: "fields", paths: ["H"] },
114
+ /* Config singletons: one record for the whole file. */
115
+ constants: { kind: "singleton" },
116
+ visuals: { kind: "singleton" },
117
+ /* Files that DO have `name` but need more of the record to be unique. Each is
118
+ * still not fully unique (see the header) - the residual collisions are
119
+ * reported, never guessed. */
120
+ object: { kind: "fields", paths: ["type", "name"] },
121
+ vault: { kind: "fields", paths: ["type", "name"] },
122
+ ego_item: { kind: "fields", paths: ["name"] },
123
+ };
124
+
125
+ /**
126
+ * Files with a declared key spec, sorted. Exported so a test can assert the set
127
+ * in BOTH directions (a file wrongly added and a file wrongly removed).
128
+ */
129
+ export const KEYED_RECORD_FILES: readonly string[] =
130
+ Object.keys(RECORD_KEY_SPECS).sort();
131
+
132
+ /** The key spec for a file: the declared one, or `name` by default. */
133
+ export function keySpecFor(file: string): RecordKeySpec {
134
+ return RECORD_KEY_SPECS[file] ?? { kind: "fields", paths: ["name"] };
135
+ }
136
+
137
+ function atPath(record: JsonRecord, path: string): JsonValue | undefined {
138
+ let cur: JsonValue | undefined = record;
139
+ for (const part of path.split(".")) {
140
+ if (typeof cur !== "object" || cur === null || Array.isArray(cur)) {
141
+ return undefined;
142
+ }
143
+ cur = (cur as JsonRecord)[part];
144
+ if (cur === undefined) return undefined;
145
+ }
146
+ return cur;
147
+ }
148
+
149
+ /**
150
+ * The slug half of a record's ref within `file`, or null when this record has no
151
+ * derivable identity (a missing key field, or a key field that is not a scalar).
152
+ * Null means "not addressable"; it never means "drop the record".
153
+ */
154
+ export function recordKey(
155
+ file: string,
156
+ record: unknown,
157
+ spec: RecordKeySpec = keySpecFor(file),
158
+ ): string | null {
159
+ if (typeof record !== "object" || record === null || Array.isArray(record)) {
160
+ return null;
161
+ }
162
+ if (spec.kind === "singleton") return slugify(file);
163
+ const parts: string[] = [];
164
+ for (const path of spec.paths) {
165
+ const value = atPath(record as JsonRecord, path);
166
+ if (typeof value !== "string" && typeof value !== "number") return null;
167
+ const slug = slugify(String(value));
168
+ if (slug.length === 0) return null;
169
+ parts.push(slug);
170
+ }
171
+ return parts.join("--");
172
+ }
173
+
174
+ /** A human phrase for what a file's identity is, for problem messages. */
175
+ export function keyDescription(file: string): string {
176
+ const spec = keySpecFor(file);
177
+ return spec.kind === "singleton"
178
+ ? `the whole file (one config record, ref "<pack>:${slugify(file)}")`
179
+ : spec.paths.join(" + ");
180
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Deterministic pack load-order resolution.
3
+ *
4
+ * Dependencies load before dependents (topological order); ties break by the
5
+ * caller's INPUT ORDER, so the order is reproducible on every machine given the
6
+ * same pack list. Cycles and missing dependencies are hard errors - a mod set
7
+ * either composes deterministically or fails loudly before play.
8
+ *
9
+ * TIES USED TO BREAK LEXICOGRAPHICALLY BY ID, and that was a defect, not a
10
+ * preference. Determinism was the goal and input order delivers it just as well:
11
+ * the caller's list is itself deterministic (the stored enabled array, an
12
+ * external manager's load-order.json, or `?mods=a,b`). Sorting by id instead
13
+ * DISCARDED the player's choice, while the mod manager went on offering "Move
14
+ * later (loads last, wins conflicts)" (web/src/mods.ts) - a reorder that changed
15
+ * nothing for any two packs with no dependency edge between them, which is most
16
+ * pairs. Vortex/MO2 semantics require the deployed order to decide, so the one
17
+ * thing the resolver must not do is invent an order of its own.
18
+ *
19
+ * Dependency edges still win outright: input order only decides among packs the
20
+ * graph leaves free, which is exactly what a load-order list is for.
21
+ *
22
+ * Beyond hard `dependencies`, MOD_LIFECYCLE.md section 3 defines two more
23
+ * ordering inputs, both soft (their absence is never an error):
24
+ *
25
+ * - `optionalDependencies`: if the named pack is present, it loads first
26
+ * and its version is checked against the declared range exactly like a
27
+ * hard dependency; if it is absent, it is silently skipped.
28
+ * - `loadAfter` / `loadBefore`: pure ordering hints among present packs,
29
+ * with no version semantics. `loadBefore` is implemented as the mirror
30
+ * of `loadAfter` (X.loadBefore = [Y] adds the same edge as Y.loadAfter
31
+ * = [X]).
32
+ *
33
+ * All of these contribute edges to the same topological sort, so a cycle
34
+ * created by mixing dependencies with loadAfter/loadBefore is rejected
35
+ * exactly like a dependency cycle.
36
+ */
37
+
38
+ import type { PackManifest } from "./manifest.js";
39
+ import { ManifestError } from "./manifest.js";
40
+ import { satisfies, SemverError } from "./semver.js";
41
+
42
+ export class ResolveError extends Error {}
43
+
44
+ /**
45
+ * Verify a present dependency's version range, throwing ResolveError with a
46
+ * plain-language message naming the fix. Used for both hard `dependencies`
47
+ * and `optionalDependencies` that happen to be present.
48
+ */
49
+ function checkVersionRange(
50
+ dependentId: string,
51
+ depId: string,
52
+ range: string,
53
+ byId: ReadonlyMap<string, PackManifest>,
54
+ ): void {
55
+ const dep = byId.get(depId) as PackManifest;
56
+ let ok: boolean;
57
+ try {
58
+ ok = satisfies(dep.version, range);
59
+ } catch (err) {
60
+ const reason = err instanceof SemverError ? err.message : String(err);
61
+ throw new ResolveError(
62
+ `pack ${dependentId} declares an invalid version range "${range}" for ${depId}: ${reason}`,
63
+ );
64
+ }
65
+ if (!ok) {
66
+ throw new ResolveError(
67
+ `pack ${dependentId} requires ${depId} ${range} but ${dep.version} is installed`,
68
+ );
69
+ }
70
+ }
71
+
72
+ /** Order manifests so every pack follows all of its dependencies. */
73
+ export function resolveLoadOrder(
74
+ manifests: readonly PackManifest[],
75
+ ): PackManifest[] {
76
+ const byId = new Map<string, PackManifest>();
77
+ for (const m of manifests) {
78
+ if (byId.has(m.id)) {
79
+ throw new ManifestError(`duplicate pack id: ${m.id}`);
80
+ }
81
+ byId.set(m.id, m);
82
+ }
83
+
84
+ for (const m of manifests) {
85
+ for (const [dep, range] of Object.entries(m.dependencies ?? {})) {
86
+ if (!byId.has(dep)) {
87
+ throw new ResolveError(`pack ${m.id} requires missing pack ${dep}`);
88
+ }
89
+ checkVersionRange(m.id, dep, range, byId);
90
+ }
91
+ for (const [dep, range] of Object.entries(m.optionalDependencies ?? {})) {
92
+ if (!byId.has(dep)) continue; // absence of an optional dependency is not an error
93
+ checkVersionRange(m.id, dep, range, byId);
94
+ }
95
+ }
96
+
97
+ // Collect, per pack, the full set of ids that must load before it: hard
98
+ // deps, present optional deps, present loadAfter, and the reverse edge
99
+ // for every present pack's loadBefore. Built as per-id Sets so that the
100
+ // same edge declared twice (e.g. both a hard dependency and a loadAfter
101
+ // entry) collapses to one edge instead of a duplicate that would corrupt
102
+ // the Kahn in-degree bookkeeping below.
103
+ const prereqs = new Map<string, Set<string>>();
104
+ for (const m of manifests) {
105
+ prereqs.set(m.id, new Set());
106
+ }
107
+ for (const m of manifests) {
108
+ const set = prereqs.get(m.id) as Set<string>;
109
+ for (const dep of Object.keys(m.dependencies ?? {})) {
110
+ set.add(dep);
111
+ }
112
+ for (const dep of Object.keys(m.optionalDependencies ?? {})) {
113
+ if (byId.has(dep)) set.add(dep);
114
+ }
115
+ for (const after of m.loadAfter ?? []) {
116
+ if (byId.has(after)) set.add(after);
117
+ }
118
+ }
119
+ for (const m of manifests) {
120
+ for (const before of m.loadBefore ?? []) {
121
+ // m must load before `before`: that is the same edge as
122
+ // `before`.loadAfter including m, so add it to before's prereq set.
123
+ const set = prereqs.get(before);
124
+ if (set !== undefined) set.add(m.id);
125
+ }
126
+ }
127
+
128
+ const remainingDeps = new Map<string, Set<string>>();
129
+ const dependents = new Map<string, string[]>();
130
+ for (const m of manifests) {
131
+ remainingDeps.set(m.id, new Set(prereqs.get(m.id)));
132
+ for (const dep of prereqs.get(m.id) as Set<string>) {
133
+ const list = dependents.get(dep) ?? [];
134
+ list.push(m.id);
135
+ dependents.set(dep, list);
136
+ }
137
+ }
138
+
139
+ /* The caller's position for each id, which is what ties break on. */
140
+ const inputAt = new Map<string, number>();
141
+ manifests.forEach((m, i) => inputAt.set(m.id, i));
142
+ const at = (id: string): number => inputAt.get(id) ?? 0;
143
+
144
+ // Kahn with a frontier kept in input order, so the result is deterministic
145
+ // without the resolver imposing an order the player did not choose.
146
+ const frontier = [...remainingDeps.entries()]
147
+ .filter(([, deps]) => deps.size === 0)
148
+ .map(([id]) => id)
149
+ .sort((a, b) => at(a) - at(b));
150
+ const order: PackManifest[] = [];
151
+
152
+ while (frontier.length > 0) {
153
+ const id = frontier.shift() as string;
154
+ order.push(byId.get(id) as PackManifest);
155
+ for (const dependent of dependents.get(id) ?? []) {
156
+ const deps = remainingDeps.get(dependent) as Set<string>;
157
+ deps.delete(id);
158
+ if (deps.size === 0) {
159
+ // Insert keeping the frontier in input order.
160
+ const pos = frontier.findIndex((f) => at(f) > at(dependent));
161
+ if (pos === -1) frontier.push(dependent);
162
+ else frontier.splice(pos, 0, dependent);
163
+ }
164
+ }
165
+ }
166
+
167
+ if (order.length !== manifests.length) {
168
+ const stuck = [...remainingDeps.entries()]
169
+ .filter(([, deps]) => deps.size > 0)
170
+ .map(([id]) => id)
171
+ .sort();
172
+ throw new ResolveError(`dependency cycle among packs: ${stuck.join(", ")}`);
173
+ }
174
+ return order;
175
+ }