@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/loader.ts ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Content-pack loading: turn a resolved set of packs into the merged per-file
3
+ * record arrays a host binds into the running game.
4
+ *
5
+ * This is the join MOD_INTEGRATION_PLAN.md (Wave 1, W1.1) calls for. Until now
6
+ * the composition engine (resolveLoadOrder + composePacks) had no runtime
7
+ * caller: the game bound a single hard-coded pack directly. This wraps the
8
+ * engine into one entry point so the base game and every mod flow through the
9
+ * same pipeline, and a mod's records / patches / replaces / removes /
10
+ * fieldPatches actually take effect.
11
+ *
12
+ * The host (web / cli) owns the glue: it reads packs off disk or bundle, calls
13
+ * composeContentPacks, assembles its GamePack from the result, and hands that
14
+ * to core bindCore. Core stays mod-sdk-agnostic (it only ever sees a merged
15
+ * pack), so the layering the audit relied on is preserved.
16
+ *
17
+ * TWO MERGE PHASES, AND WHY
18
+ *
19
+ * composePacks needs a unique string `name` on every added record, because that
20
+ * is the identity it builds refs from. Measured over the shipped core pack, 24
21
+ * of the 44 record files satisfy that and 20 do not (14 carry no string `name`
22
+ * at all; 6 carry names that slug to the same ref). So:
23
+ *
24
+ * 1. COMPOSED FILES (24) go through composePacks, which merges records
25
+ * per-record and appends a later pack's additions after an earlier pack's.
26
+ * 2. PASSTHROUGH FILES (20) keep whole-file semantics for `records` - the last
27
+ * provider in load order wins the file - because a mod that ships
28
+ * `constants.json` means "use mine", not "add a second constants record",
29
+ * and the host binds one. Per-record ops are then applied on top, in load
30
+ * order, against the winning array (applyPassthroughOps below), using the
31
+ * per-file identity declared in record-key.ts.
32
+ *
33
+ * NOTHING IS DROPPED IN SILENCE. This is the invariant the whole file exists to
34
+ * hold. Until 2026-07-29 phase 2 did not exist: a `patches` / `replaces` /
35
+ * `fieldPatches` / `removes` entry aimed at any of the 20 was stripped from the
36
+ * contribution before compose ever saw it, so a mod author shipped a valid op
37
+ * and got no effect and no message - the worst failure mode there is, because a
38
+ * feature that appears to work cannot be found by review. Now every per-record
39
+ * op either takes effect or produces a line in `ComposedContent.problems`
40
+ * naming the pack, the file, the op and the ref. Whole-file replacement of a
41
+ * passthrough file is reported the same way, because it silently discards
42
+ * whatever the previous provider (usually core) put there.
43
+ *
44
+ * `problems` is reported rather than thrown because the web host composes at
45
+ * module scope with no try (packages/web/src/pack.ts), so a throw here is a
46
+ * blank page rather than a message. It is the same shape of channel the pack
47
+ * readers already use (`problems: readonly string[]`), so a host concatenates it
48
+ * into the list it already shows.
49
+ */
50
+
51
+ import type { PackManifest } from "./manifest.js";
52
+ import { slugify } from "./manifest.js";
53
+ import { resolveLoadOrder } from "./resolve.js";
54
+ import { composePacks, mergePatch } from "./compose.js";
55
+ import type { FileContribution, JsonRecord, PackContent } from "./compose.js";
56
+ import { applyFieldPatch } from "./patch.js";
57
+ import { keyDescription, keySpecFor, recordKey, RECORD_KEY_SPECS } from "./record-key.js";
58
+
59
+ /**
60
+ * One pack as the host loaded it: its manifest plus its per-file contributions.
61
+ * The base game is the degenerate case where every file is records-only.
62
+ */
63
+ export interface LoadedPack {
64
+ manifest: PackManifest;
65
+ /** fileName -> that file's contribution (records / patches / ...). */
66
+ files: Record<string, FileContribution>;
67
+ }
68
+
69
+ /** The merged content: per-file record arrays, in deterministic order. */
70
+ export interface ComposedContent {
71
+ /** fileName -> composed record array. */
72
+ records: Record<string, unknown[]>;
73
+ /** Files merged per-record through the full FileContribution model. */
74
+ composedFiles: string[];
75
+ /** Files whose `records` pass through last-wins (nameless or name-colliding). */
76
+ passthroughFiles: string[];
77
+ /**
78
+ * Every mod-facing operation that could NOT be honoured, in one line each,
79
+ * naming the pack, the file, the op and the record. Empty for a clean set.
80
+ * A host shows these next to the pack-reading problems it already collects.
81
+ */
82
+ problems: string[];
83
+ }
84
+
85
+ function isNamedRecord(r: unknown): r is JsonRecord {
86
+ return (
87
+ typeof r === "object" &&
88
+ r !== null &&
89
+ !Array.isArray(r) &&
90
+ typeof (r as { name?: unknown }).name === "string" &&
91
+ (r as { name: string }).name.length > 0
92
+ );
93
+ }
94
+
95
+ /**
96
+ * A pack's added records for one file are per-record composable only if they
97
+ * are all name-keyed and their refs (pack:slug(name)) do not collide - exactly
98
+ * the two conditions composePacks would otherwise throw on.
99
+ */
100
+ function recordsComposable(records: readonly unknown[]): boolean {
101
+ const slugs = new Set<string>();
102
+ for (const r of records) {
103
+ if (!isNamedRecord(r)) return false;
104
+ const slug = slugify(r["name"] as string);
105
+ if (slugs.has(slug)) return false;
106
+ slugs.add(slug);
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /** Reorder loaded packs into resolved load order (dependencies first). */
112
+ function orderPacks(packs: readonly LoadedPack[]): LoadedPack[] {
113
+ const ordered = resolveLoadOrder(packs.map((p) => p.manifest));
114
+ const byId = new Map(packs.map((p) => [p.manifest.id, p]));
115
+ return ordered.map((m) => byId.get(m.id) as LoadedPack);
116
+ }
117
+
118
+ /** The pack id a ref's "<owner>:<slug>" prefix names. */
119
+ function ownerOf(ref: string): string {
120
+ const at = ref.indexOf(":");
121
+ return at === -1 ? "" : ref.slice(0, at);
122
+ }
123
+
124
+ /** compose.ts mayModify: a pack may only touch its own or a declared dep's records. */
125
+ function mayModify(m: PackManifest, ownerPack: string): boolean {
126
+ return ownerPack === m.id || (m.dependencies ?? {})[ownerPack] !== undefined;
127
+ }
128
+
129
+ /** The four per-record op kinds, in the order composePacks applies them. */
130
+ type OpKind = "patch" | "replace" | "fieldPatch" | "remove";
131
+
132
+ /** Every per-record op in one contribution, flattened, in apply order. */
133
+ function perRecordOps(
134
+ contrib: FileContribution,
135
+ ): Array<{ kind: OpKind; ref: string }> {
136
+ const out: Array<{ kind: OpKind; ref: string }> = [];
137
+ for (const ref of Object.keys(contrib.patches ?? {})) out.push({ kind: "patch", ref });
138
+ for (const ref of Object.keys(contrib.replaces ?? {})) out.push({ kind: "replace", ref });
139
+ for (const ref of Object.keys(contrib.fieldPatches ?? {})) {
140
+ out.push({ kind: "fieldPatch", ref });
141
+ }
142
+ for (const ref of contrib.removes ?? []) out.push({ kind: "remove", ref });
143
+ return out;
144
+ }
145
+
146
+ const OP_VERB: Readonly<Record<OpKind, string>> = {
147
+ patch: "patches",
148
+ replace: "replaces",
149
+ fieldPatch: "fieldPatches",
150
+ remove: "removes",
151
+ };
152
+
153
+ /**
154
+ * Apply every pack's per-record ops to a PASSTHROUGH file's winning record
155
+ * array, in load order, and report every op that could not be honoured.
156
+ *
157
+ * Returns the (possibly new) record array; the input is never mutated, and when
158
+ * no pack contributes an op the input array is returned unchanged so routing the
159
+ * base game alone through this path stays a no-op by reference.
160
+ */
161
+ function applyPassthroughOps(
162
+ file: string,
163
+ records: readonly unknown[],
164
+ ordered: readonly LoadedPack[],
165
+ providerId: string,
166
+ problems: string[],
167
+ ): unknown[] {
168
+ const hasOps = ordered.some(
169
+ (p) => p.files[file] !== undefined && perRecordOps(p.files[file] as FileContribution).length > 0,
170
+ );
171
+ if (!hasOps) return records as unknown[];
172
+
173
+ /* No declared identity (history: chart/next/roll/phrase are all values a mod
174
+ * would change). Every op is reported and none is applied - the one honest
175
+ * answer, because inventing a key here would mis-merge instead of dropping. */
176
+ if (RECORD_KEY_SPECS[file] === undefined && !recordsKeyedByName(records)) {
177
+ for (const pack of ordered) {
178
+ const contrib = pack.files[file];
179
+ if (!contrib) continue;
180
+ for (const { kind, ref } of perRecordOps(contrib)) {
181
+ problems.push(
182
+ `${pack.manifest.id}: ${file} ${OP_VERB[kind]} "${ref}", but ${file} records have no per-record identity, so only whole-file replacement can change them`,
183
+ );
184
+ }
185
+ }
186
+ return records as unknown[];
187
+ }
188
+
189
+ const spec = keySpecFor(file);
190
+ const working: Array<JsonRecord | null> = records.map((r) =>
191
+ typeof r === "object" && r !== null && !Array.isArray(r) ? (r as JsonRecord) : null,
192
+ );
193
+
194
+ /* ref -> EVERY position claiming it. Kept as a list rather than resolved to a
195
+ * winner on insert, so "not found" and "claimed twice" are one lookup with two
196
+ * outcomes: there is nowhere for a first-claim-wins fallback to hide. A ref two
197
+ * records claim is unaddressable and reported; both records stay in the game. */
198
+ const claims = new Map<string, number[]>();
199
+ records.forEach((record, i) => {
200
+ const key = recordKey(file, record, spec);
201
+ if (key === null) return; // unkeyable record: stays in the game, not addressable
202
+ const ref = `${providerId}:${key}`;
203
+ const at = claims.get(ref);
204
+ if (at) at.push(i);
205
+ else claims.set(ref, [i]);
206
+ });
207
+
208
+ const removed = new Set<number>();
209
+
210
+ const reject = (pid: string, kind: OpKind, ref: string, why: string): void => {
211
+ problems.push(`${pid}: ${file} ${OP_VERB[kind]} "${ref}", but ${why}`);
212
+ };
213
+
214
+ /** Resolve a ref to a live index, or report why it cannot be touched. */
215
+ const resolve = (
216
+ pack: LoadedPack,
217
+ kind: OpKind,
218
+ ref: string,
219
+ ): number | null => {
220
+ const pid = pack.manifest.id;
221
+ const claimants = claims.get(ref) ?? [];
222
+ if (claimants.length > 1) {
223
+ reject(
224
+ pid,
225
+ kind,
226
+ ref,
227
+ `${claimants.length} ${file} records share that identity (${keyDescription(file)}), so it cannot be addressed - patch a record with a unique identity instead`,
228
+ );
229
+ return null;
230
+ }
231
+ const at = claimants[0];
232
+ if (at === undefined) {
233
+ reject(
234
+ pid,
235
+ kind,
236
+ ref,
237
+ `no such record exists in ${file} (identity is ${keyDescription(file)})`,
238
+ );
239
+ return null;
240
+ }
241
+ if (removed.has(at)) {
242
+ reject(pid, kind, ref, "an earlier pack already removed it");
243
+ return null;
244
+ }
245
+ if (!mayModify(pack.manifest, ownerOf(ref))) {
246
+ reject(
247
+ pid,
248
+ kind,
249
+ ref,
250
+ `${pid} does not declare ${ownerOf(ref)} as a dependency`,
251
+ );
252
+ return null;
253
+ }
254
+ return at;
255
+ };
256
+
257
+ for (const pack of ordered) {
258
+ const contrib = pack.files[file];
259
+ if (!contrib) continue;
260
+
261
+ for (const [ref, body] of Object.entries(contrib.patches ?? {})) {
262
+ const at = resolve(pack, "patch", ref);
263
+ if (at === null) continue;
264
+ working[at] = mergePatch(working[at] as JsonRecord, body);
265
+ }
266
+ for (const [ref, body] of Object.entries(contrib.replaces ?? {})) {
267
+ const at = resolve(pack, "replace", ref);
268
+ if (at === null) continue;
269
+ working[at] = body;
270
+ }
271
+ for (const [ref, ops] of Object.entries(contrib.fieldPatches ?? {})) {
272
+ const at = resolve(pack, "fieldPatch", ref);
273
+ if (at === null) continue;
274
+ working[at] = applyFieldPatch(working[at] as JsonRecord, ops);
275
+ }
276
+ for (const ref of contrib.removes ?? []) {
277
+ const at = resolve(pack, "remove", ref);
278
+ if (at === null) continue;
279
+ removed.add(at);
280
+ }
281
+ }
282
+
283
+ const out: unknown[] = [];
284
+ working.forEach((r, i) => {
285
+ if (!removed.has(i)) out.push(r === null ? records[i] : r);
286
+ });
287
+ return out;
288
+ }
289
+
290
+ /** Whether a passthrough file's records are name-keyed after all (mod-only file). */
291
+ function recordsKeyedByName(records: readonly unknown[]): boolean {
292
+ return records.some((r) => isNamedRecord(r));
293
+ }
294
+
295
+ /**
296
+ * Compose a set of loaded packs into merged per-file record arrays. With a
297
+ * single pack (the base game alone) the output is record-identical to the
298
+ * input: every record object is preserved by reference and its order is
299
+ * unchanged, so routing the base game through this path is a no-op.
300
+ */
301
+ export function composeContentPacks(
302
+ packs: readonly LoadedPack[],
303
+ ): ComposedContent {
304
+ const ordered = orderPacks(packs);
305
+ const problems: string[] = [];
306
+
307
+ const fileNames = new Set<string>();
308
+ for (const p of ordered) {
309
+ for (const f of Object.keys(p.files)) fileNames.add(f);
310
+ }
311
+
312
+ // Classify each file: per-record composable, or whole-file passthrough.
313
+ const composable = new Set<string>();
314
+ for (const f of fileNames) {
315
+ let ok = true;
316
+ for (const p of ordered) {
317
+ const contrib = p.files[f];
318
+ if (contrib?.records && !recordsComposable(contrib.records)) {
319
+ ok = false;
320
+ break;
321
+ }
322
+ }
323
+ if (ok) composable.add(f);
324
+ }
325
+
326
+ const contents: PackContent[] = ordered.map((p) => {
327
+ const files: Record<string, FileContribution> = {};
328
+ for (const [f, contrib] of Object.entries(p.files)) {
329
+ if (composable.has(f)) files[f] = contrib;
330
+ }
331
+ return { manifest: p.manifest, files };
332
+ });
333
+
334
+ const game = composePacks(contents);
335
+
336
+ const out: Record<string, unknown[]> = {};
337
+ for (const [file, table] of game) {
338
+ out[file] = [...table.values()].map((r) => r.value);
339
+ }
340
+
341
+ /* Passthrough files, in two phases (see the header): the last provider in load
342
+ * order wins the whole file, then every pack's per-record ops apply on top. */
343
+ for (const f of fileNames) {
344
+ if (composable.has(f)) continue;
345
+
346
+ let providerId = "";
347
+ for (const p of ordered) {
348
+ const contrib = p.files[f];
349
+ if (!contrib?.records) continue;
350
+ if (providerId !== "") {
351
+ /* Whole-file replacement is destructive and used to be invisible: the
352
+ * previous provider's records simply vanished. Say so. */
353
+ problems.push(
354
+ `${p.manifest.id}: ${f} replaces the whole file, discarding ${(out[f] as unknown[]).length} record(s) from ${providerId} - ${f} records are not name-keyed, so a whole file is the only thing that can be added to it`,
355
+ );
356
+ }
357
+ out[f] = [...contrib.records];
358
+ providerId = p.manifest.id;
359
+ }
360
+
361
+ if (providerId === "") {
362
+ /* SAFETY NET, not a live path. A file is only classified passthrough
363
+ * because some pack's `records` for it are not name-keyed, and such a pack
364
+ * is by definition a provider - so today providerId is never "" here. The
365
+ * equivalent case for a COMPOSABLE file (a mod patches a file nobody
366
+ * supplies records for) is loud already: composePacks throws
367
+ * "patch target ... does not exist". This branch is kept, cordoned and
368
+ * documented rather than deleted, because if the classification ever
369
+ * changes its absence would be a silent drop - the exact failure this file
370
+ * exists to prevent. Covered by loader.test.ts only through the composable
371
+ * counterpart. */
372
+ for (const p of ordered) {
373
+ const contrib = p.files[f];
374
+ if (!contrib) continue;
375
+ for (const { kind, ref } of perRecordOps(contrib)) {
376
+ problems.push(
377
+ `${p.manifest.id}: ${f} ${OP_VERB[kind]} "${ref}", but no pack supplies any ${f} records`,
378
+ );
379
+ }
380
+ }
381
+ continue;
382
+ }
383
+
384
+ out[f] = applyPassthroughOps(f, out[f] as unknown[], ordered, providerId, problems);
385
+ }
386
+
387
+ return {
388
+ records: out,
389
+ composedFiles: [...composable].sort(),
390
+ passthroughFiles: [...fileNames].filter((f) => !composable.has(f)).sort(),
391
+ problems,
392
+ };
393
+ }