@px-lsp/protocol 0.1.0 → 0.2.1
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 +674 -674
- package/README.md +25 -25
- package/dist/calendar.d.ts +71 -0
- package/dist/calendar.js +182 -0
- package/dist/calendarFile.d.ts +26 -0
- package/dist/calendarFile.js +109 -0
- package/dist/calendarLoc.d.ts +60 -0
- package/dist/calendarLoc.js +101 -0
- package/dist/configDir.d.ts +17 -0
- package/dist/configDir.js +79 -0
- package/dist/descriptorMod.d.ts +21 -0
- package/dist/descriptorMod.js +61 -5
- package/dist/kinds.d.ts +72 -0
- package/dist/kinds.js +186 -0
- package/dist/protocol.d.ts +661 -4
- package/dist/protocol.js +159 -2
- package/dist/workshopMeta.d.ts +40 -0
- package/dist/workshopMeta.js +146 -0
- package/package.json +1 -1
- package/src/arrays.ts +16 -16
- package/src/calendar.ts +183 -0
- package/src/calendarFile.ts +81 -0
- package/src/calendarLoc.ts +159 -0
- package/src/configDir.ts +47 -0
- package/src/constants.ts +12 -12
- package/src/descriptorMetadata.ts +101 -101
- package/src/descriptorMod.ts +414 -354
- package/src/errorLogParser.ts +136 -136
- package/src/fsWalk.ts +126 -126
- package/src/kinds.ts +205 -0
- package/src/locProperties.ts +43 -43
- package/src/locRefs.ts +38 -38
- package/src/modName.ts +18 -18
- package/src/protocol.ts +2156 -1459
- package/src/regex.ts +19 -19
- package/src/suppression.ts +178 -178
- package/src/tigerParser.ts +79 -79
- package/src/translationCore.ts +140 -140
- package/src/types.ts +90 -90
- package/src/workshopMeta.ts +127 -0
package/src/descriptorMod.ts
CHANGED
|
@@ -1,354 +1,414 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Knowledge table + validator for Paradox launcher `.mod` descriptor files
|
|
3
|
-
* (`descriptor.mod` inside the mod folder, `<name>.mod` next to it).
|
|
4
|
-
*
|
|
5
|
-
* The key set and tag list come from the official launcher docs
|
|
6
|
-
* (the Mod_structure page on the official wiki) cross-checked against 86 real .mod
|
|
7
|
-
* files (launcher-generated + Workshop). No vscode imports: unit-testable.
|
|
8
|
-
*/
|
|
9
|
-
import * as fs from "fs";
|
|
10
|
-
import * as path from "path";
|
|
11
|
-
|
|
12
|
-
export interface DescriptorField {
|
|
13
|
-
key: string;
|
|
14
|
-
/** Launcher refuses/misbehaves without it. */
|
|
15
|
-
required: boolean;
|
|
16
|
-
/** May appear multiple times (replace_path). */
|
|
17
|
-
repeatable: boolean;
|
|
18
|
-
/** Only meaningful in the outer `<name>.mod` file, ignored in descriptor.mod. */
|
|
19
|
-
outerOnly: boolean;
|
|
20
|
-
/** One-line label shown next to the completion item. */
|
|
21
|
-
summary: string;
|
|
22
|
-
/** Markdown: what the value means and what to put in. */
|
|
23
|
-
doc: string;
|
|
24
|
-
/** VS Code snippet inserted on completion (placeholder = example value). */
|
|
25
|
-
snippet: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const DESCRIPTOR_FIELDS: DescriptorField[] = [
|
|
29
|
-
{
|
|
30
|
-
key: "name",
|
|
31
|
-
required: true,
|
|
32
|
-
repeatable: false,
|
|
33
|
-
outerOnly: false,
|
|
34
|
-
summary: "Display name in the launcher and on Steam Workshop",
|
|
35
|
-
doc:
|
|
36
|
-
"The name players see in the launcher's mod list and on the Workshop page. " +
|
|
37
|
-
"Needs at least 3 characters for a Workshop upload.\n\n" +
|
|
38
|
-
'```\nname="My Mod"\n```',
|
|
39
|
-
snippet: 'name="${1:My Mod}"',
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
key: "version",
|
|
43
|
-
required: true,
|
|
44
|
-
repeatable: false,
|
|
45
|
-
outerOnly: false,
|
|
46
|
-
summary: "Your mod's own version number (NOT the game version)",
|
|
47
|
-
doc:
|
|
48
|
-
"Free-form version string shown in the launcher. Bump it when you release " +
|
|
49
|
-
"an update so players can tell versions apart. This is about your mod, " +
|
|
50
|
-
"not the game - the game version goes in `supported_version`.\n\n" +
|
|
51
|
-
'```\nversion="0.1.0"\n```',
|
|
52
|
-
snippet: 'version="${1:0.1.0}"',
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
key: "supported_version",
|
|
56
|
-
required: true,
|
|
57
|
-
repeatable: false,
|
|
58
|
-
outerOnly: false,
|
|
59
|
-
summary: "Newest game version the mod works with",
|
|
60
|
-
doc:
|
|
61
|
-
"The launcher compares this against the installed game and marks the mod " +
|
|
62
|
-
"out of date when the game is newer. A `*` wildcard keeps the mod valid " +
|
|
63
|
-
"for every hotfix of a patch.\n\n" +
|
|
64
|
-
'```\nsupported_version="1.19.*"\n```',
|
|
65
|
-
snippet: 'supported_version="${1:1.19.*}"',
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
key: "tags",
|
|
69
|
-
required: false,
|
|
70
|
-
repeatable: false,
|
|
71
|
-
outerOnly: false,
|
|
72
|
-
summary: "Launcher / Workshop category tags (one quoted tag per line)",
|
|
73
|
-
doc:
|
|
74
|
-
"Categories players can filter by in the launcher and on the Workshop. " +
|
|
75
|
-
"Pick from the launcher's list (completion inside the block offers all of " +
|
|
76
|
-
"them); a Workshop upload needs at least one.\n\n" +
|
|
77
|
-
'```\ntags={\n\t"Gameplay"\n\t"Events"\n}\n```',
|
|
78
|
-
snippet: 'tags={\n\t"${1:Gameplay}"\n}',
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
key: "path",
|
|
82
|
-
required: false,
|
|
83
|
-
repeatable: false,
|
|
84
|
-
outerOnly: true,
|
|
85
|
-
summary: "Where the mod folder is - outer <name>.mod file only",
|
|
86
|
-
doc:
|
|
87
|
-
"Tells the launcher where the mod's files live. Absolute or relative to " +
|
|
88
|
-
"the game's user directory, forward slashes only.\n\n" +
|
|
89
|
-
"**Leave this line out of `descriptor.mod`** - it is ignored there, and a " +
|
|
90
|
-
"copied absolute path breaks when the mod is shared.\n\n" +
|
|
91
|
-
'```\npath="mod/my_mod"\n```',
|
|
92
|
-
snippet: 'path="${1:mod/my_mod}"',
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
key: "remote_file_id",
|
|
96
|
-
required: false,
|
|
97
|
-
repeatable: false,
|
|
98
|
-
outerOnly: false,
|
|
99
|
-
summary: "Steam Workshop item ID (set automatically on first upload)",
|
|
100
|
-
doc:
|
|
101
|
-
"Links the local mod to its Workshop page so updates go to the same item. " +
|
|
102
|
-
"The launcher fills this in when you first upload - you only ever set it " +
|
|
103
|
-
"by hand to reconnect a mod to an existing Workshop item. Digits only.\n\n" +
|
|
104
|
-
'```\nremote_file_id="2962333032"\n```',
|
|
105
|
-
snippet: 'remote_file_id="${1:123456789}"',
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
key: "picture",
|
|
109
|
-
required: false,
|
|
110
|
-
repeatable: false,
|
|
111
|
-
outerOnly: false,
|
|
112
|
-
summary: "Launcher thumbnail image (file inside the mod folder)",
|
|
113
|
-
doc:
|
|
114
|
-
"Image shown next to the mod in the launcher. Steam Workshop ignores it " +
|
|
115
|
-
"and always uses `thumbnail.png` in the mod root instead (1:1, max 1 MB).\n\n" +
|
|
116
|
-
'```\npicture="thumbnail.png"\n```',
|
|
117
|
-
snippet: 'picture="${1:thumbnail.png}"',
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
key: "replace_path",
|
|
121
|
-
required: false,
|
|
122
|
-
repeatable: true,
|
|
123
|
-
outerOnly: false,
|
|
124
|
-
summary: "Unload an entire vanilla folder (repeat per folder)",
|
|
125
|
-
doc:
|
|
126
|
-
"The game skips every vanilla file under this folder, so only your mod's " +
|
|
127
|
-
"version of it exists. One line per folder, forward slashes, relative to " +
|
|
128
|
-
"the game root. Used by total conversions to drop vanilla history, " +
|
|
129
|
-
"titles, cultures etc. wholesale - do not use it for ordinary overrides.\n\n" +
|
|
130
|
-
'```\nreplace_path="history/characters"\nreplace_path="common/landed_titles"\n```',
|
|
131
|
-
snippet: 'replace_path="${1:history/characters}"',
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
key: "dependencies",
|
|
135
|
-
required: false,
|
|
136
|
-
repeatable: false,
|
|
137
|
-
outerOnly: false,
|
|
138
|
-
summary: "Mods that must load BEFORE this one",
|
|
139
|
-
doc:
|
|
140
|
-
"The launcher sorts every listed mod above this one in the load order. " +
|
|
141
|
-
"Use the exact `name` of the other mod, one quoted name per line. Mostly " +
|
|
142
|
-
"for submods and compatibility patches.\n\n" +
|
|
143
|
-
'```\ndependencies={\n\t"A Game of Thrones"\n}\n```',
|
|
144
|
-
snippet: 'dependencies={\n\t"${1:Name of the parent mod}"\n}',
|
|
145
|
-
},
|
|
146
|
-
];
|
|
147
|
-
|
|
148
|
-
export const DESCRIPTOR_FIELD_MAP: ReadonlyMap<string, DescriptorField> = new Map(
|
|
149
|
-
DESCRIPTOR_FIELDS.map((f) => [f.key, f])
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
/** The launcher's fixed tag categories (Mod_structure wiki page, launcher UI). */
|
|
153
|
-
export const LAUNCHER_TAGS: string[] = [
|
|
154
|
-
"Alternative History",
|
|
155
|
-
"Balance",
|
|
156
|
-
"Bookmarks",
|
|
157
|
-
"Character Focuses",
|
|
158
|
-
"Character Interactions",
|
|
159
|
-
"Culture",
|
|
160
|
-
"Decisions",
|
|
161
|
-
"Events",
|
|
162
|
-
"Fixes",
|
|
163
|
-
"Gameplay",
|
|
164
|
-
"Graphics",
|
|
165
|
-
"Historical",
|
|
166
|
-
"Map",
|
|
167
|
-
"Portraits",
|
|
168
|
-
"Religion",
|
|
169
|
-
"Schemes",
|
|
170
|
-
"Sound",
|
|
171
|
-
"Total Conversion",
|
|
172
|
-
"Translation",
|
|
173
|
-
"Utilities",
|
|
174
|
-
"Warfare",
|
|
175
|
-
];
|
|
176
|
-
|
|
177
|
-
// ---- parsing -------------------------------------------------------------------
|
|
178
|
-
|
|
179
|
-
export interface DescriptorEntry {
|
|
180
|
-
key: string;
|
|
181
|
-
/** 0-based line of the key. */
|
|
182
|
-
line: number;
|
|
183
|
-
/** Column range of the key on its line. */
|
|
184
|
-
startCol: number;
|
|
185
|
-
endCol: number;
|
|
186
|
-
/** Raw text right of `=` (trimmed, quotes kept), "" when the value is a block. */
|
|
187
|
-
value: string;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Line-based parse of the flat key=value format. Only top-level keys are
|
|
192
|
-
* entries; lines inside a `{ }` block (tags, dependencies) are skipped.
|
|
193
|
-
*/
|
|
194
|
-
export function parseDescriptor(text: string): DescriptorEntry[] {
|
|
195
|
-
const entries: DescriptorEntry[] = [];
|
|
196
|
-
let depth = 0;
|
|
197
|
-
const lines = text.split(/\r?\n/);
|
|
198
|
-
for (let i = 0; i < lines.length; i++) {
|
|
199
|
-
const line = lines[i];
|
|
200
|
-
const noComment = line.replace(/#.*$/, "");
|
|
201
|
-
if (depth === 0) {
|
|
202
|
-
// Tolerate a UTF-8 BOM on the first line.
|
|
203
|
-
const m = /^(\uFEFF?\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(noComment);
|
|
204
|
-
if (m) {
|
|
205
|
-
const startCol = m[1].length;
|
|
206
|
-
entries.push({
|
|
207
|
-
key: m[2],
|
|
208
|
-
line: i,
|
|
209
|
-
startCol,
|
|
210
|
-
endCol: startCol + m[2].length,
|
|
211
|
-
value: m[3].trim().startsWith("{") ? "" : m[3].trim(),
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
for (const ch of noComment) {
|
|
216
|
-
if (ch === "{") depth++;
|
|
217
|
-
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return entries;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ---- validation ------------------------------------------------------------------
|
|
224
|
-
|
|
225
|
-
export interface DescriptorIssue {
|
|
226
|
-
code:
|
|
227
|
-
| "descriptor-missing-field"
|
|
228
|
-
| "descriptor-unknown-key"
|
|
229
|
-
| "descriptor-duplicate-key"
|
|
230
|
-
| "descriptor-path-ignored";
|
|
231
|
-
severity: "error" | "warning";
|
|
232
|
-
line: number;
|
|
233
|
-
startCol: number;
|
|
234
|
-
endCol: number;
|
|
235
|
-
message: string;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Structural checks on a .mod file. Everything here is certain: the key set is
|
|
240
|
-
* closed (launcher docs) and required-ness is the launcher's own behavior.
|
|
241
|
-
*/
|
|
242
|
-
export function validateDescriptor(text: string, opts: { isDescriptorFile: boolean }): DescriptorIssue[] {
|
|
243
|
-
const issues: DescriptorIssue[] = [];
|
|
244
|
-
const entries = parseDescriptor(text);
|
|
245
|
-
const seen = new Map<string, DescriptorEntry>();
|
|
246
|
-
|
|
247
|
-
for (const e of entries) {
|
|
248
|
-
const field = DESCRIPTOR_FIELD_MAP.get(e.key);
|
|
249
|
-
const at = { line: e.line, startCol: e.startCol, endCol: e.endCol };
|
|
250
|
-
if (!field) {
|
|
251
|
-
issues.push({
|
|
252
|
-
code: "descriptor-unknown-key",
|
|
253
|
-
severity: "warning",
|
|
254
|
-
...at,
|
|
255
|
-
message: `'${e.key}' is not a .mod descriptor key; the launcher ignores it.`,
|
|
256
|
-
});
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
if (seen.has(e.key) && !field.repeatable) {
|
|
260
|
-
issues.push({
|
|
261
|
-
code: "descriptor-duplicate-key",
|
|
262
|
-
severity: "warning",
|
|
263
|
-
...at,
|
|
264
|
-
message: `'${e.key}' appears more than once; only the last value counts.`,
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
seen.set(e.key, e);
|
|
268
|
-
if (e.key === "path" && opts.isDescriptorFile) {
|
|
269
|
-
issues.push({
|
|
270
|
-
code: "descriptor-path-ignored",
|
|
271
|
-
severity: "warning",
|
|
272
|
-
...at,
|
|
273
|
-
message:
|
|
274
|
-
"path= belongs in the outer <name>.mod file; inside descriptor.mod the launcher ignores it, " +
|
|
275
|
-
"and a machine-specific path leaks when the mod is shared.",
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
for (const field of DESCRIPTOR_FIELDS) {
|
|
281
|
-
if (!field.required || seen.has(field.key)) continue;
|
|
282
|
-
// supported_version: the launcher still lists the mod, it just cannot
|
|
283
|
-
// check compatibility - a warning, not an error.
|
|
284
|
-
const isHard = field.key !== "supported_version";
|
|
285
|
-
issues.push({
|
|
286
|
-
code: "descriptor-missing-field",
|
|
287
|
-
severity: isHard ? "error" : "warning",
|
|
288
|
-
line: 0,
|
|
289
|
-
startCol: 0,
|
|
290
|
-
endCol: 200,
|
|
291
|
-
message: isHard
|
|
292
|
-
? `Missing ${field.key}= - the launcher needs it to list the mod.`
|
|
293
|
-
: "Missing supported_version= - the launcher cannot tell which game version the mod is for.",
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return issues;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* The mod's display name from `<dir>/descriptor.mod` (`name="..."`), or null
|
|
302
|
-
* when the file or field is missing/unreadable. Lets UI surfaces say WHICH mod
|
|
303
|
-
* something comes from ("Community Flavor Pack") instead of a generic "mod".
|
|
304
|
-
*/
|
|
305
|
-
export function readDescriptorName(dir: string): string | null {
|
|
306
|
-
let text: string;
|
|
307
|
-
try {
|
|
308
|
-
text = fs.readFileSync(path.join(dir, "descriptor.mod"), "utf8");
|
|
309
|
-
} catch {
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
const entry = parseDescriptor(text).find((e) => e.key === "name");
|
|
313
|
-
if (!entry) return null;
|
|
314
|
-
const value = entry.value.replace(/^"([^]*)"$/, "$1").trim();
|
|
315
|
-
return value === "" ? null : value;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* The
|
|
320
|
-
*
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge table + validator for Paradox launcher `.mod` descriptor files
|
|
3
|
+
* (`descriptor.mod` inside the mod folder, `<name>.mod` next to it).
|
|
4
|
+
*
|
|
5
|
+
* The key set and tag list come from the official launcher docs
|
|
6
|
+
* (the Mod_structure page on the official wiki) cross-checked against 86 real .mod
|
|
7
|
+
* files (launcher-generated + Workshop). No vscode imports: unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
|
|
12
|
+
export interface DescriptorField {
|
|
13
|
+
key: string;
|
|
14
|
+
/** Launcher refuses/misbehaves without it. */
|
|
15
|
+
required: boolean;
|
|
16
|
+
/** May appear multiple times (replace_path). */
|
|
17
|
+
repeatable: boolean;
|
|
18
|
+
/** Only meaningful in the outer `<name>.mod` file, ignored in descriptor.mod. */
|
|
19
|
+
outerOnly: boolean;
|
|
20
|
+
/** One-line label shown next to the completion item. */
|
|
21
|
+
summary: string;
|
|
22
|
+
/** Markdown: what the value means and what to put in. */
|
|
23
|
+
doc: string;
|
|
24
|
+
/** VS Code snippet inserted on completion (placeholder = example value). */
|
|
25
|
+
snippet: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DESCRIPTOR_FIELDS: DescriptorField[] = [
|
|
29
|
+
{
|
|
30
|
+
key: "name",
|
|
31
|
+
required: true,
|
|
32
|
+
repeatable: false,
|
|
33
|
+
outerOnly: false,
|
|
34
|
+
summary: "Display name in the launcher and on Steam Workshop",
|
|
35
|
+
doc:
|
|
36
|
+
"The name players see in the launcher's mod list and on the Workshop page. " +
|
|
37
|
+
"Needs at least 3 characters for a Workshop upload.\n\n" +
|
|
38
|
+
'```\nname="My Mod"\n```',
|
|
39
|
+
snippet: 'name="${1:My Mod}"',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: "version",
|
|
43
|
+
required: true,
|
|
44
|
+
repeatable: false,
|
|
45
|
+
outerOnly: false,
|
|
46
|
+
summary: "Your mod's own version number (NOT the game version)",
|
|
47
|
+
doc:
|
|
48
|
+
"Free-form version string shown in the launcher. Bump it when you release " +
|
|
49
|
+
"an update so players can tell versions apart. This is about your mod, " +
|
|
50
|
+
"not the game - the game version goes in `supported_version`.\n\n" +
|
|
51
|
+
'```\nversion="0.1.0"\n```',
|
|
52
|
+
snippet: 'version="${1:0.1.0}"',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "supported_version",
|
|
56
|
+
required: true,
|
|
57
|
+
repeatable: false,
|
|
58
|
+
outerOnly: false,
|
|
59
|
+
summary: "Newest game version the mod works with",
|
|
60
|
+
doc:
|
|
61
|
+
"The launcher compares this against the installed game and marks the mod " +
|
|
62
|
+
"out of date when the game is newer. A `*` wildcard keeps the mod valid " +
|
|
63
|
+
"for every hotfix of a patch.\n\n" +
|
|
64
|
+
'```\nsupported_version="1.19.*"\n```',
|
|
65
|
+
snippet: 'supported_version="${1:1.19.*}"',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "tags",
|
|
69
|
+
required: false,
|
|
70
|
+
repeatable: false,
|
|
71
|
+
outerOnly: false,
|
|
72
|
+
summary: "Launcher / Workshop category tags (one quoted tag per line)",
|
|
73
|
+
doc:
|
|
74
|
+
"Categories players can filter by in the launcher and on the Workshop. " +
|
|
75
|
+
"Pick from the launcher's list (completion inside the block offers all of " +
|
|
76
|
+
"them); a Workshop upload needs at least one.\n\n" +
|
|
77
|
+
'```\ntags={\n\t"Gameplay"\n\t"Events"\n}\n```',
|
|
78
|
+
snippet: 'tags={\n\t"${1:Gameplay}"\n}',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: "path",
|
|
82
|
+
required: false,
|
|
83
|
+
repeatable: false,
|
|
84
|
+
outerOnly: true,
|
|
85
|
+
summary: "Where the mod folder is - outer <name>.mod file only",
|
|
86
|
+
doc:
|
|
87
|
+
"Tells the launcher where the mod's files live. Absolute or relative to " +
|
|
88
|
+
"the game's user directory, forward slashes only.\n\n" +
|
|
89
|
+
"**Leave this line out of `descriptor.mod`** - it is ignored there, and a " +
|
|
90
|
+
"copied absolute path breaks when the mod is shared.\n\n" +
|
|
91
|
+
'```\npath="mod/my_mod"\n```',
|
|
92
|
+
snippet: 'path="${1:mod/my_mod}"',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "remote_file_id",
|
|
96
|
+
required: false,
|
|
97
|
+
repeatable: false,
|
|
98
|
+
outerOnly: false,
|
|
99
|
+
summary: "Steam Workshop item ID (set automatically on first upload)",
|
|
100
|
+
doc:
|
|
101
|
+
"Links the local mod to its Workshop page so updates go to the same item. " +
|
|
102
|
+
"The launcher fills this in when you first upload - you only ever set it " +
|
|
103
|
+
"by hand to reconnect a mod to an existing Workshop item. Digits only.\n\n" +
|
|
104
|
+
'```\nremote_file_id="2962333032"\n```',
|
|
105
|
+
snippet: 'remote_file_id="${1:123456789}"',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "picture",
|
|
109
|
+
required: false,
|
|
110
|
+
repeatable: false,
|
|
111
|
+
outerOnly: false,
|
|
112
|
+
summary: "Launcher thumbnail image (file inside the mod folder)",
|
|
113
|
+
doc:
|
|
114
|
+
"Image shown next to the mod in the launcher. Steam Workshop ignores it " +
|
|
115
|
+
"and always uses `thumbnail.png` in the mod root instead (1:1, max 1 MB).\n\n" +
|
|
116
|
+
'```\npicture="thumbnail.png"\n```',
|
|
117
|
+
snippet: 'picture="${1:thumbnail.png}"',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "replace_path",
|
|
121
|
+
required: false,
|
|
122
|
+
repeatable: true,
|
|
123
|
+
outerOnly: false,
|
|
124
|
+
summary: "Unload an entire vanilla folder (repeat per folder)",
|
|
125
|
+
doc:
|
|
126
|
+
"The game skips every vanilla file under this folder, so only your mod's " +
|
|
127
|
+
"version of it exists. One line per folder, forward slashes, relative to " +
|
|
128
|
+
"the game root. Used by total conversions to drop vanilla history, " +
|
|
129
|
+
"titles, cultures etc. wholesale - do not use it for ordinary overrides.\n\n" +
|
|
130
|
+
'```\nreplace_path="history/characters"\nreplace_path="common/landed_titles"\n```',
|
|
131
|
+
snippet: 'replace_path="${1:history/characters}"',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "dependencies",
|
|
135
|
+
required: false,
|
|
136
|
+
repeatable: false,
|
|
137
|
+
outerOnly: false,
|
|
138
|
+
summary: "Mods that must load BEFORE this one",
|
|
139
|
+
doc:
|
|
140
|
+
"The launcher sorts every listed mod above this one in the load order. " +
|
|
141
|
+
"Use the exact `name` of the other mod, one quoted name per line. Mostly " +
|
|
142
|
+
"for submods and compatibility patches.\n\n" +
|
|
143
|
+
'```\ndependencies={\n\t"A Game of Thrones"\n}\n```',
|
|
144
|
+
snippet: 'dependencies={\n\t"${1:Name of the parent mod}"\n}',
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
export const DESCRIPTOR_FIELD_MAP: ReadonlyMap<string, DescriptorField> = new Map(
|
|
149
|
+
DESCRIPTOR_FIELDS.map((f) => [f.key, f])
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/** The launcher's fixed tag categories (Mod_structure wiki page, launcher UI). */
|
|
153
|
+
export const LAUNCHER_TAGS: string[] = [
|
|
154
|
+
"Alternative History",
|
|
155
|
+
"Balance",
|
|
156
|
+
"Bookmarks",
|
|
157
|
+
"Character Focuses",
|
|
158
|
+
"Character Interactions",
|
|
159
|
+
"Culture",
|
|
160
|
+
"Decisions",
|
|
161
|
+
"Events",
|
|
162
|
+
"Fixes",
|
|
163
|
+
"Gameplay",
|
|
164
|
+
"Graphics",
|
|
165
|
+
"Historical",
|
|
166
|
+
"Map",
|
|
167
|
+
"Portraits",
|
|
168
|
+
"Religion",
|
|
169
|
+
"Schemes",
|
|
170
|
+
"Sound",
|
|
171
|
+
"Total Conversion",
|
|
172
|
+
"Translation",
|
|
173
|
+
"Utilities",
|
|
174
|
+
"Warfare",
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
// ---- parsing -------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export interface DescriptorEntry {
|
|
180
|
+
key: string;
|
|
181
|
+
/** 0-based line of the key. */
|
|
182
|
+
line: number;
|
|
183
|
+
/** Column range of the key on its line. */
|
|
184
|
+
startCol: number;
|
|
185
|
+
endCol: number;
|
|
186
|
+
/** Raw text right of `=` (trimmed, quotes kept), "" when the value is a block. */
|
|
187
|
+
value: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Line-based parse of the flat key=value format. Only top-level keys are
|
|
192
|
+
* entries; lines inside a `{ }` block (tags, dependencies) are skipped.
|
|
193
|
+
*/
|
|
194
|
+
export function parseDescriptor(text: string): DescriptorEntry[] {
|
|
195
|
+
const entries: DescriptorEntry[] = [];
|
|
196
|
+
let depth = 0;
|
|
197
|
+
const lines = text.split(/\r?\n/);
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
const line = lines[i];
|
|
200
|
+
const noComment = line.replace(/#.*$/, "");
|
|
201
|
+
if (depth === 0) {
|
|
202
|
+
// Tolerate a UTF-8 BOM on the first line.
|
|
203
|
+
const m = /^(\uFEFF?\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(noComment);
|
|
204
|
+
if (m) {
|
|
205
|
+
const startCol = m[1].length;
|
|
206
|
+
entries.push({
|
|
207
|
+
key: m[2],
|
|
208
|
+
line: i,
|
|
209
|
+
startCol,
|
|
210
|
+
endCol: startCol + m[2].length,
|
|
211
|
+
value: m[3].trim().startsWith("{") ? "" : m[3].trim(),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const ch of noComment) {
|
|
216
|
+
if (ch === "{") depth++;
|
|
217
|
+
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return entries;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- validation ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
export interface DescriptorIssue {
|
|
226
|
+
code:
|
|
227
|
+
| "descriptor-missing-field"
|
|
228
|
+
| "descriptor-unknown-key"
|
|
229
|
+
| "descriptor-duplicate-key"
|
|
230
|
+
| "descriptor-path-ignored";
|
|
231
|
+
severity: "error" | "warning";
|
|
232
|
+
line: number;
|
|
233
|
+
startCol: number;
|
|
234
|
+
endCol: number;
|
|
235
|
+
message: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Structural checks on a .mod file. Everything here is certain: the key set is
|
|
240
|
+
* closed (launcher docs) and required-ness is the launcher's own behavior.
|
|
241
|
+
*/
|
|
242
|
+
export function validateDescriptor(text: string, opts: { isDescriptorFile: boolean }): DescriptorIssue[] {
|
|
243
|
+
const issues: DescriptorIssue[] = [];
|
|
244
|
+
const entries = parseDescriptor(text);
|
|
245
|
+
const seen = new Map<string, DescriptorEntry>();
|
|
246
|
+
|
|
247
|
+
for (const e of entries) {
|
|
248
|
+
const field = DESCRIPTOR_FIELD_MAP.get(e.key);
|
|
249
|
+
const at = { line: e.line, startCol: e.startCol, endCol: e.endCol };
|
|
250
|
+
if (!field) {
|
|
251
|
+
issues.push({
|
|
252
|
+
code: "descriptor-unknown-key",
|
|
253
|
+
severity: "warning",
|
|
254
|
+
...at,
|
|
255
|
+
message: `'${e.key}' is not a .mod descriptor key; the launcher ignores it.`,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (seen.has(e.key) && !field.repeatable) {
|
|
260
|
+
issues.push({
|
|
261
|
+
code: "descriptor-duplicate-key",
|
|
262
|
+
severity: "warning",
|
|
263
|
+
...at,
|
|
264
|
+
message: `'${e.key}' appears more than once; only the last value counts.`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
seen.set(e.key, e);
|
|
268
|
+
if (e.key === "path" && opts.isDescriptorFile) {
|
|
269
|
+
issues.push({
|
|
270
|
+
code: "descriptor-path-ignored",
|
|
271
|
+
severity: "warning",
|
|
272
|
+
...at,
|
|
273
|
+
message:
|
|
274
|
+
"path= belongs in the outer <name>.mod file; inside descriptor.mod the launcher ignores it, " +
|
|
275
|
+
"and a machine-specific path leaks when the mod is shared.",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const field of DESCRIPTOR_FIELDS) {
|
|
281
|
+
if (!field.required || seen.has(field.key)) continue;
|
|
282
|
+
// supported_version: the launcher still lists the mod, it just cannot
|
|
283
|
+
// check compatibility - a warning, not an error.
|
|
284
|
+
const isHard = field.key !== "supported_version";
|
|
285
|
+
issues.push({
|
|
286
|
+
code: "descriptor-missing-field",
|
|
287
|
+
severity: isHard ? "error" : "warning",
|
|
288
|
+
line: 0,
|
|
289
|
+
startCol: 0,
|
|
290
|
+
endCol: 200,
|
|
291
|
+
message: isHard
|
|
292
|
+
? `Missing ${field.key}= - the launcher needs it to list the mod.`
|
|
293
|
+
: "Missing supported_version= - the launcher cannot tell which game version the mod is for.",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return issues;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The mod's display name from `<dir>/descriptor.mod` (`name="..."`), or null
|
|
302
|
+
* when the file or field is missing/unreadable. Lets UI surfaces say WHICH mod
|
|
303
|
+
* something comes from ("Community Flavor Pack") instead of a generic "mod".
|
|
304
|
+
*/
|
|
305
|
+
export function readDescriptorName(dir: string): string | null {
|
|
306
|
+
let text: string;
|
|
307
|
+
try {
|
|
308
|
+
text = fs.readFileSync(path.join(dir, "descriptor.mod"), "utf8");
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const entry = parseDescriptor(text).find((e) => e.key === "name");
|
|
313
|
+
if (!entry) return null;
|
|
314
|
+
const value = entry.value.replace(/^"([^]*)"$/, "$1").trim();
|
|
315
|
+
return value === "" ? null : value;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The quoted strings inside a top-level `<key>={ "A" "B" }` block of a .mod
|
|
320
|
+
* text, in file order; empty when the block is missing.
|
|
321
|
+
*/
|
|
322
|
+
export function readDescriptorBlock(text: string, key: string): string[] {
|
|
323
|
+
// Comments first: a commented-out entry is not an entry.
|
|
324
|
+
const block = new RegExp(`(?:^|\\n)[ \\t]*${key}[ \\t]*=[ \\t]*\\{([^}]*)\\}`).exec(
|
|
325
|
+
text.replace(/#[^\n]*/g, "")
|
|
326
|
+
);
|
|
327
|
+
if (!block) return [];
|
|
328
|
+
return [...block[1].matchAll(/"([^"]*)"/g)].map((m) => m[1].trim()).filter((s) => s !== "");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The mod names inside `<dir>/descriptor.mod`'s `dependencies={ "A" "B" }`
|
|
333
|
+
* block, in file order; empty when the file or the block is missing. The
|
|
334
|
+
* launcher matches these against the other mods' `name=`, not against their
|
|
335
|
+
* Workshop id, so that is what the caller compares them with.
|
|
336
|
+
*/
|
|
337
|
+
export function readDescriptorDependencies(dir: string): string[] {
|
|
338
|
+
let text: string;
|
|
339
|
+
try {
|
|
340
|
+
text = fs.readFileSync(path.join(dir, "descriptor.mod"), "utf8");
|
|
341
|
+
} catch {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
return readDescriptorBlock(text, "dependencies");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* `text` with the top-level `key="value"` entry replaced, or appended when the
|
|
349
|
+
* key is absent. Only scalar entries: a key whose value is a block is left
|
|
350
|
+
* alone and the entry is appended instead. Line endings and a leading BOM
|
|
351
|
+
* survive untouched; the appended line follows the file's dominant EOL.
|
|
352
|
+
* The value is made descriptor-safe like upsertDescriptorBlock's quoting:
|
|
353
|
+
* the format has no escape, so double quotes become apostrophes and line
|
|
354
|
+
* breaks collapse to one space.
|
|
355
|
+
*/
|
|
356
|
+
export function upsertDescriptorValue(text: string, key: string, value: string): string {
|
|
357
|
+
const v = value.replace(/"/g, "'").replace(/\s*\r?\n\s*/g, " ");
|
|
358
|
+
const entry = parseDescriptor(text).find((e) => e.key === key && e.value !== "");
|
|
359
|
+
if (entry) {
|
|
360
|
+
const lines = text.split(/(\r?\n)/); // keep separators at odd indices
|
|
361
|
+
const idx = entry.line * 2;
|
|
362
|
+
lines[idx] = lines[idx].replace(
|
|
363
|
+
/=\s*("[^"]*"|\S+)([ \t]*(#.*)?)$/,
|
|
364
|
+
(_m, _old, tail: string) => `="${v}"${tail}`
|
|
365
|
+
);
|
|
366
|
+
return lines.join("");
|
|
367
|
+
}
|
|
368
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
369
|
+
const sep = text === "" || text.endsWith("\n") ? "" : eol;
|
|
370
|
+
return `${text}${sep}${key}="${v}"${eol}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `text` with the top-level `key={...}` block replaced by one holding exactly
|
|
375
|
+
* `values` (quoted, tab-indented, the file's EOL), or appended when absent.
|
|
376
|
+
* Descriptor blocks are flat, so the block ends at the first `}`-only line.
|
|
377
|
+
*/
|
|
378
|
+
export function upsertDescriptorBlock(text: string, key: string, values: string[]): string {
|
|
379
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
380
|
+
const q = (v: string) => `"${v.replace(/"/g, "'")}"`;
|
|
381
|
+
const block = [`${key}={`, ...values.map((v) => `\t${q(v)}`), `}`].join(eol);
|
|
382
|
+
const lines = text.split(/\r?\n/);
|
|
383
|
+
const open = lines.findIndex((l) => new RegExp(`^\\s*${key}\\s*=\\s*\\{`).test(l));
|
|
384
|
+
if (open >= 0) {
|
|
385
|
+
let close = open;
|
|
386
|
+
// A one-line block (`tags={ "x" }`) closes on its own line.
|
|
387
|
+
if (!/\}\s*(#.*)?$/.test(lines[open])) {
|
|
388
|
+
while (close < lines.length - 1 && !/^\s*\}\s*(#.*)?$/.test(lines[close])) close++;
|
|
389
|
+
}
|
|
390
|
+
lines.splice(open, close - open + 1, block);
|
|
391
|
+
return lines.join(eol);
|
|
392
|
+
}
|
|
393
|
+
const sep = text === "" || text.endsWith("\n") ? "" : eol;
|
|
394
|
+
return `${text}${sep}${block}${eol}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** "1.19.0.6" -> "1.19.*" (the wildcard form that survives hotfixes). */
|
|
398
|
+
export function wildcardVersion(raw: string): string | null {
|
|
399
|
+
const m = /^(\d+)\.(\d+)/.exec(raw.trim());
|
|
400
|
+
return m ? `${m[1]}.${m[2]}.*` : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** A launcher-correct starter descriptor.mod. */
|
|
404
|
+
export function scaffoldDescriptor(modName: string, supportedVersion: string): string {
|
|
405
|
+
return [
|
|
406
|
+
'version="0.1.0"',
|
|
407
|
+
"tags={",
|
|
408
|
+
'\t"Gameplay"',
|
|
409
|
+
"}",
|
|
410
|
+
`name="${modName.replace(/"/g, "'")}"`,
|
|
411
|
+
`supported_version="${supportedVersion}"`,
|
|
412
|
+
"",
|
|
413
|
+
].join("\n");
|
|
414
|
+
}
|