@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,242 @@
1
+ /**
2
+ * The pre-launch conflict report (MOD_LIFECYCLE section 3, P7 phase 6).
3
+ *
4
+ * Before a session starts, the app shows the player every record touched
5
+ * by more than one pack: which fields each pack wrote, who wins, and a
6
+ * plain-language line for anything that actually collides. Nothing is
7
+ * silent, nothing is a surprise at runtime.
8
+ *
9
+ * Two kinds of "touch" feed the report:
10
+ * - Field patches (patch.ts) and the coarse whole-record `patches` merge
11
+ * (compose.ts) both write specific fields. A record is contested when
12
+ * two or more distinct packs write it this way; a field is a collision
13
+ * only when two or more of those packs write the SAME field with an
14
+ * order-dependent op (composeFieldPatches decides that - reused here,
15
+ * not reimplemented).
16
+ * - A whole-record `replaces` or `removes` is always worth reporting,
17
+ * regardless of how many other packs touched the record: it overrides
18
+ * whatever the owning pack (and any patches) established, and that is
19
+ * exactly the kind of surprise this report exists to surface.
20
+ *
21
+ * Pure and deterministic: given the same (already load-ordered) pack list,
22
+ * the report is always the same value.
23
+ */
24
+
25
+ import type { FileContribution, JsonRecord, PackContent } from "./compose.js";
26
+ import type { FieldConflict, FieldPatch } from "./patch.js";
27
+ import { composeFieldPatches, touchedFields } from "./patch.js";
28
+
29
+ /** One field a contested record's contributing packs wrote. */
30
+ export interface FieldTouch {
31
+ /** The dot-path written (as used by FieldOp / touchedFields). */
32
+ path: string;
33
+ /** Packs that wrote this field, in load order. */
34
+ owners: string[];
35
+ /** The pack whose write wins: the last one in load order. */
36
+ winner: string;
37
+ }
38
+
39
+ /** A later pack overriding a record's entire body outright. */
40
+ export interface RecordOverride {
41
+ /** The pack that performed the override (last one, if more than one did). */
42
+ pack: string;
43
+ kind: "replace" | "remove";
44
+ }
45
+
46
+ /** One contested record: touched by more than one pack. */
47
+ export interface RecordConflict {
48
+ /** The record reference, e.g. "core:kobold". */
49
+ ref: string;
50
+ /** The file the record lives in, e.g. "monster". */
51
+ file: string;
52
+ /**
53
+ * Every pack that contributed a field patch, coarse patch, replace, or
54
+ * remove to this record, in load order. Does not include the owning
55
+ * pack unless the owner is itself one of those contributors.
56
+ */
57
+ contributingPacks: string[];
58
+ /**
59
+ * Every field any contributing pack wrote (via fieldPatches, or a
60
+ * top-level key of a coarse `patches` body), with who wrote it and who
61
+ * wins. Empty when the record was only touched by a whole-record
62
+ * replace/remove.
63
+ */
64
+ fields: FieldTouch[];
65
+ /** Same-field collisions among `fields` - empty when none collided. */
66
+ collisions: FieldConflict[];
67
+ /** Present when a pack replaced or removed the record outright. */
68
+ override?: RecordOverride;
69
+ /** One plain-language line per collision and per override. */
70
+ humanLines: string[];
71
+ }
72
+
73
+ /** The full pre-launch conflict report: contested records only. */
74
+ export interface ConflictReport {
75
+ records: RecordConflict[];
76
+ }
77
+
78
+ /** The pack id a ref's "<owner>:<slug>" prefix names. */
79
+ function ownerOf(ref: string): string {
80
+ const at = ref.indexOf(":");
81
+ return at === -1 ? "" : ref.slice(0, at);
82
+ }
83
+
84
+ /**
85
+ * Turn a coarse whole-record patch body into field ops, one per top-level
86
+ * key, so it runs through the same field-conflict engine as fieldPatches:
87
+ * nested objects merge (mirroring mergePatch), everything else - including
88
+ * an explicit null delete - is a straight `set` for reporting purposes.
89
+ */
90
+ function coarsePatchOps(body: JsonRecord): FieldPatch {
91
+ const ops: FieldPatch = [];
92
+ for (const [key, value] of Object.entries(body)) {
93
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
94
+ ops.push({ op: "merge", path: key, value });
95
+ } else {
96
+ ops.push({ op: "set", path: key, value });
97
+ }
98
+ }
99
+ return ops;
100
+ }
101
+
102
+ /** Per-record bookkeeping while walking the pack list once, in order. */
103
+ interface RecordEntry {
104
+ fieldContribs: { owner: string; ops: FieldPatch }[];
105
+ modifiers: Set<string>;
106
+ overrides: { pack: string; kind: "replace" | "remove" }[];
107
+ }
108
+
109
+ function entryFor(
110
+ byFile: Map<string, Map<string, RecordEntry>>,
111
+ file: string,
112
+ ref: string,
113
+ ): RecordEntry {
114
+ let table = byFile.get(file);
115
+ if (!table) {
116
+ table = new Map();
117
+ byFile.set(file, table);
118
+ }
119
+ let entry = table.get(ref);
120
+ if (!entry) {
121
+ entry = { fieldContribs: [], modifiers: new Set(), overrides: [] };
122
+ table.set(ref, entry);
123
+ }
124
+ return entry;
125
+ }
126
+
127
+ /** For every field any contributing pack wrote: who wrote it, who wins. */
128
+ function fieldBreakdown(
129
+ contribs: ReadonlyArray<{ owner: string; ops: FieldPatch }>,
130
+ ): FieldTouch[] {
131
+ const writers = new Map<string, string[]>();
132
+ for (const { owner, ops } of contribs) {
133
+ for (const path of touchedFields(ops)) {
134
+ const owners = writers.get(path) ?? [];
135
+ if (owners[owners.length - 1] !== owner) owners.push(owner);
136
+ writers.set(path, owners);
137
+ }
138
+ }
139
+ const out: FieldTouch[] = [];
140
+ for (const [path, owners] of writers) {
141
+ out.push({ path, owners, winner: owners[owners.length - 1] as string });
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** Two, or several, pack names joined for a human sentence. */
147
+ function joinOwners(owners: readonly string[]): string {
148
+ if (owners.length <= 1) return owners.join("");
149
+ if (owners.length === 2) return `${owners[0]} and ${owners[1]}`;
150
+ return `${owners.slice(0, -1).join(", ")} and ${owners[owners.length - 1]}`;
151
+ }
152
+
153
+ /** The record name half of a ref, for readable human lines ("core:kobold" -> "kobold"). */
154
+ function refName(ref: string): string {
155
+ const at = ref.indexOf(":");
156
+ return at === -1 ? ref : ref.slice(at + 1);
157
+ }
158
+
159
+ /**
160
+ * Compute the pre-launch conflict report over an already load-ordered pack
161
+ * list (see resolveLoadOrder). Only contested records get an entry:
162
+ * additive changes - distinct records, or a single pack touching a record -
163
+ * produce none.
164
+ */
165
+ export function computeConflictReport(packs: readonly PackContent[]): ConflictReport {
166
+ const byFile = new Map<string, Map<string, RecordEntry>>();
167
+
168
+ for (const pack of packs) {
169
+ const pid = pack.manifest.id;
170
+ for (const [file, contrib] of Object.entries(pack.files) as [
171
+ string,
172
+ FileContribution,
173
+ ][]) {
174
+ for (const [ref, ops] of Object.entries(contrib.fieldPatches ?? {})) {
175
+ const entry = entryFor(byFile, file, ref);
176
+ entry.fieldContribs.push({ owner: pid, ops });
177
+ entry.modifiers.add(pid);
178
+ }
179
+ for (const [ref, body] of Object.entries(contrib.patches ?? {})) {
180
+ const entry = entryFor(byFile, file, ref);
181
+ entry.fieldContribs.push({ owner: pid, ops: coarsePatchOps(body) });
182
+ entry.modifiers.add(pid);
183
+ }
184
+ for (const [ref] of Object.entries(contrib.replaces ?? {})) {
185
+ const entry = entryFor(byFile, file, ref);
186
+ entry.modifiers.add(pid);
187
+ entry.overrides.push({ pack: pid, kind: "replace" });
188
+ }
189
+ for (const ref of contrib.removes ?? []) {
190
+ const entry = entryFor(byFile, file, ref);
191
+ entry.modifiers.add(pid);
192
+ entry.overrides.push({ pack: pid, kind: "remove" });
193
+ }
194
+ }
195
+ }
196
+
197
+ const records: RecordConflict[] = [];
198
+ for (const [file, table] of byFile) {
199
+ for (const [ref, entry] of table) {
200
+ const hasOverride = entry.overrides.length > 0;
201
+ if (entry.modifiers.size < 2 && !hasOverride) continue; // additive: not contested
202
+
203
+ const { conflicts } = composeFieldPatches({}, entry.fieldContribs);
204
+ const fields = fieldBreakdown(entry.fieldContribs);
205
+ const humanLines: string[] = [];
206
+
207
+ for (const conflict of conflicts) {
208
+ const winner = conflict.owners[conflict.owners.length - 1] as string;
209
+ const verb = conflict.owners.length === 2 ? "both set" : "all set";
210
+ humanLines.push(
211
+ `${joinOwners(conflict.owners)} ${verb} ${refName(ref)}.${conflict.path}; ${winner} wins - drag to reorder.`,
212
+ );
213
+ }
214
+
215
+ let override: RecordOverride | undefined;
216
+ if (hasOverride) {
217
+ const last = entry.overrides[entry.overrides.length - 1] as {
218
+ pack: string;
219
+ kind: "replace" | "remove";
220
+ };
221
+ override = { pack: last.pack, kind: last.kind };
222
+ const verb = last.kind === "replace" ? "replaces" : "removes";
223
+ humanLines.push(
224
+ `${last.pack} ${verb} ${refName(ref)} outright, overriding ${ownerOf(ref)}'s original.`,
225
+ );
226
+ }
227
+
228
+ const record: RecordConflict = {
229
+ ref,
230
+ file,
231
+ contributingPacks: [...entry.modifiers],
232
+ fields,
233
+ collisions: conflicts,
234
+ humanLines,
235
+ };
236
+ if (override) record.override = override;
237
+ records.push(record);
238
+ }
239
+ }
240
+
241
+ return { records };
242
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @rpgm-tools/neo-angband-mod-sdk - schemas and tooling for the mod ecosystem.
3
+ *
4
+ * Three pack shapes, one loading pipeline (see docs/MODS.md):
5
+ * - content packs: schema-validated declarative JSON (safe by construction)
6
+ * - tile packs: Linoleum-style manifests with individual images and
7
+ * exact named targets, honest glyph fallback for uncovered targets
8
+ * - scripted plugins: capability-scoped sandboxed scripts (escape hatch)
9
+ *
10
+ * This package holds the pack-agnostic machinery: manifests, the
11
+ * deterministic load-order resolver, and the record composition engine
12
+ * (add/patch/replace/remove with ownership rules and provenance). The
13
+ * base game composes through this exact pipeline as pack zero.
14
+ */
15
+
16
+ export {
17
+ hasFacet,
18
+ ManifestError,
19
+ PACK_SHAPES,
20
+ packFacets,
21
+ packRef,
22
+ slugify,
23
+ validateManifest,
24
+ } from "./manifest.js";
25
+ export type {
26
+ Capability,
27
+ PackManifest,
28
+ PackRef,
29
+ PackRule,
30
+ PackShape,
31
+ PackTilePack,
32
+ } from "./manifest.js";
33
+ export { ResolveError, resolveLoadOrder } from "./resolve.js";
34
+ export { satisfies, SemverError } from "./semver.js";
35
+ export { ComposeError, composePacks, mergePatch } from "./compose.js";
36
+ export { composeContentPacks } from "./loader.js";
37
+ export type { ComposedContent, LoadedPack } from "./loader.js";
38
+ export {
39
+ KEYED_RECORD_FILES,
40
+ keyDescription,
41
+ keySpecFor,
42
+ RECORD_KEY_SPECS,
43
+ recordKey,
44
+ } from "./record-key.js";
45
+ export type { RecordKeySpec } from "./record-key.js";
46
+ export type {
47
+ ComposedRecord,
48
+ FileContribution,
49
+ JsonRecord,
50
+ JsonValue,
51
+ PackContent,
52
+ } from "./compose.js";
53
+ export {
54
+ applyFieldPatch,
55
+ composeFieldPatches,
56
+ PatchError,
57
+ touchedFields,
58
+ } from "./patch.js";
59
+ export type {
60
+ ComposedPatch,
61
+ FieldConflict,
62
+ FieldOp,
63
+ FieldPatch,
64
+ } from "./patch.js";
65
+ export { computeConflictReport } from "./conflicts.js";
66
+ export type {
67
+ ConflictReport,
68
+ FieldTouch,
69
+ RecordConflict,
70
+ RecordOverride,
71
+ } from "./conflicts.js";
72
+ export { CapabilityError, CapabilitySet, parseCapability } from "./capabilities.js";
73
+ export type { ParsedCapability } from "./capabilities.js";