@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.
- package/LICENSE.md +43 -0
- package/README.md +72 -0
- package/dist/capabilities.d.ts +116 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +170 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/compose.d.ts +71 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +118 -0
- package/dist/compose.js.map +1 -0
- package/dist/conflicts.d.ts +78 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +160 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +83 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +314 -0
- package/dist/loader.js.map +1 -0
- package/dist/manifest.d.ts +261 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +264 -0
- package/dist/manifest.js.map +1 -0
- package/dist/patch.d.ts +90 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +195 -0
- package/dist/patch.js.map +1 -0
- package/dist/record-key.d.ts +99 -0
- package/dist/record-key.d.ts.map +1 -0
- package/dist/record-key.js +157 -0
- package/dist/record-key.js.map +1 -0
- package/dist/resolve.d.ts +42 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +161 -0
- package/dist/resolve.js.map +1 -0
- package/dist/semver.d.ts +37 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +212 -0
- package/dist/semver.js.map +1 -0
- package/package.json +58 -0
- package/src/capabilities.ts +205 -0
- package/src/compose.ts +186 -0
- package/src/conflicts.ts +242 -0
- package/src/index.ts +73 -0
- package/src/loader.ts +393 -0
- package/src/manifest.ts +523 -0
- package/src/patch.ts +257 -0
- package/src/record-key.ts +180 -0
- package/src/resolve.ts +175 -0
- 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
|
+
}
|