@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/conflicts.ts
ADDED
|
@@ -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";
|