@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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-mod calendar declaration: `<mod>/.px-toolkit/calendar.json`, the
|
|
3
|
+
* JSON form of calendar.ts `CalendarSetting`. A display calendar is a fact
|
|
4
|
+
* about the mod, so it travels with the mod (committed, one per mod, read by
|
|
5
|
+
* every client and by the server itself) instead of living in one editor's
|
|
6
|
+
* window-scoped `px.calendar` setting. The setting stays as the fallback for
|
|
7
|
+
* a mod without the file.
|
|
8
|
+
*
|
|
9
|
+
* No `vscode` imports: unit-tested in plain Node.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import { sanitizeCalendar, type CalendarSetting } from "./calendar";
|
|
14
|
+
import { migrateConfigDir, resolveConfigDir, type ConfigDirNames } from "./configDir";
|
|
15
|
+
|
|
16
|
+
export const CALENDAR_FILE = "calendar.json";
|
|
17
|
+
|
|
18
|
+
export interface CalendarFile {
|
|
19
|
+
/** Where the declaration was read from (or would be written to). */
|
|
20
|
+
file: string;
|
|
21
|
+
/** The declared calendar, when the file parses and sanitizes. */
|
|
22
|
+
calendar?: CalendarSetting;
|
|
23
|
+
/** Why an existing file yields no calendar: unparsable JSON or an unusable shape. */
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The path the file is read from: the mod's config dir (legacy name included). */
|
|
28
|
+
export function calendarFilePath(modRoot: string, names: ConfigDirNames): string {
|
|
29
|
+
return path.join(resolveConfigDir(modRoot, names), CALENDAR_FILE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read `<mod>/.px-toolkit/calendar.json`. Null when the file does not exist;
|
|
34
|
+
* a `CalendarFile` without `calendar` when it exists but is not usable, so a
|
|
35
|
+
* client can say so instead of silently showing no dates.
|
|
36
|
+
*/
|
|
37
|
+
export function readCalendarFile(modRoot: string, names: ConfigDirNames): CalendarFile | null {
|
|
38
|
+
const file = calendarFilePath(modRoot, names);
|
|
39
|
+
let text: string;
|
|
40
|
+
try {
|
|
41
|
+
text = fs.readFileSync(file, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
let raw: unknown;
|
|
46
|
+
try {
|
|
47
|
+
raw = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { file, error: `not valid JSON (${(err as Error).message})` };
|
|
50
|
+
}
|
|
51
|
+
const calendar = sanitizeCalendar(raw);
|
|
52
|
+
if (!calendar) {
|
|
53
|
+
return {
|
|
54
|
+
file,
|
|
55
|
+
error:
|
|
56
|
+
'not a usable calendar: needs a whole-number "epoch" (1 or more), a non-empty "after" era label, ' +
|
|
57
|
+
'a "before" label different from "after" when present, and, when "months" is given, exactly twelve distinct names',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { file, calendar };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write the declaration into the mod (renaming a legacy config dir first,
|
|
65
|
+
* like every other config-dir write). Returns the file path.
|
|
66
|
+
*/
|
|
67
|
+
export function writeCalendarFile(modRoot: string, names: ConfigDirNames, cal: CalendarSetting): string {
|
|
68
|
+
const dir = migrateConfigDir(modRoot, names);
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
const file = path.join(dir, CALENDAR_FILE);
|
|
71
|
+
fs.writeFileSync(file, JSON.stringify(cal, null, 2) + "\n", "utf8");
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when `fsPath` is a calendar declaration file (any config dir name). */
|
|
76
|
+
export function isCalendarFile(fsPath: string, names: ConfigDirNames): boolean {
|
|
77
|
+
const parts = fsPath.split(/[\\/]/);
|
|
78
|
+
if (parts.length < 2 || parts[parts.length - 1].toLowerCase() !== CALENDAR_FILE) return false;
|
|
79
|
+
const dir = parts[parts.length - 2].toLowerCase();
|
|
80
|
+
return dir === names.configDirName.toLowerCase() || dir === names.legacyConfigDirName?.toLowerCase();
|
|
81
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the GAME-SIDE localization for a `px.calendar` display calendar:
|
|
3
|
+
* the era-math datafunction keys plus the `localization/replace/` overrides
|
|
4
|
+
* of the engine's date-format and month-name keys. This is the mirror image
|
|
5
|
+
* of calendar.ts: that file teaches the EDITOR the mapping, this one writes
|
|
6
|
+
* the mapping into the mod so the GAME displays it (the AGoT/LotR technique).
|
|
7
|
+
*
|
|
8
|
+
* Pure logic, no `vscode` imports; the extension command writes the returned
|
|
9
|
+
* files (adding the BOM) and per-game key names come in via `CalendarLocSpec`
|
|
10
|
+
* from the GameProfile, so no game knowledge lives here.
|
|
11
|
+
*/
|
|
12
|
+
import type { CalendarSetting } from "./calendar";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The loc keys a game formats dates through, verified against the game's
|
|
16
|
+
* binary/files. Lives in the GameProfile (`games/<id>/meta.ts`); a profile
|
|
17
|
+
* without one does not support generation.
|
|
18
|
+
*/
|
|
19
|
+
export interface CalendarLocSpec {
|
|
20
|
+
/**
|
|
21
|
+
* Vanilla date-format keys to override in `localization/replace/`, mapped
|
|
22
|
+
* to their format template. `{year}` and `{era}` mark where the generated
|
|
23
|
+
* era-math key references go; everything else is kept verbatim (the
|
|
24
|
+
* vanilla `$DAY$`/`$MONTH$` parameters).
|
|
25
|
+
*/
|
|
26
|
+
dateFormats: Record<string, string>;
|
|
27
|
+
/**
|
|
28
|
+
* Engine month-name loc keys, first month first: [longKey, shortKey].
|
|
29
|
+
* Overridden only when the calendar declares custom months, and only when
|
|
30
|
+
* it declares exactly as many months as the engine has.
|
|
31
|
+
*/
|
|
32
|
+
monthKeys?: [string, string][];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One file the generator wants written, path relative to the mod root. */
|
|
36
|
+
export interface GeneratedLocFile {
|
|
37
|
+
/** Forward slashes; the writer joins it onto the mod root. */
|
|
38
|
+
relPath: string;
|
|
39
|
+
/** Content without BOM; the writer prepends it. */
|
|
40
|
+
content: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CalendarLocResult {
|
|
44
|
+
files: GeneratedLocFile[];
|
|
45
|
+
/** Facts the modder should know after generating (limits, follow-ups). */
|
|
46
|
+
notes: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Keys the generated files define; exported so tests and docs stay in sync. */
|
|
50
|
+
export const CAL_YEAR_KEY = "PX_CAL_YEAR";
|
|
51
|
+
export const CAL_ERA_KEY = "PX_CAL_ERA";
|
|
52
|
+
|
|
53
|
+
/** Era labels sit inside '...' CString literals; a quote would break the
|
|
54
|
+
* datafunction silently, which is exactly the failure class we exist to
|
|
55
|
+
* prevent. */
|
|
56
|
+
function cstring(label: string): string {
|
|
57
|
+
return label.replace(/'/g, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `[Select_int32(...)]` era math for the year, mirroring
|
|
62
|
+
* calendar.ts `displayYear`: y >= epoch shows as y - (epoch - 1), below as
|
|
63
|
+
* epoch - y. A single-era calendar (no `before`) clamps to 1 instead, so
|
|
64
|
+
* pre-epoch years never display as 0 or negative.
|
|
65
|
+
*/
|
|
66
|
+
function yearExpr(cal: CalendarSetting): string {
|
|
67
|
+
const year = "'(int32)$YEAR|q$'";
|
|
68
|
+
const toAfter = `Subtract_int32( ${year}, '(int32)${cal.epoch - 1}' )`;
|
|
69
|
+
if (!cal.before) return `[Max_int32( ${toAfter}, '(int32)1' )]`;
|
|
70
|
+
const toBefore = `Subtract_int32( '(int32)${cal.epoch}', ${year} )`;
|
|
71
|
+
return `[Select_int32( GreaterThanOrEqualTo_int32( ${year}, '(int32)${cal.epoch}' ), ${toAfter}, ${toBefore} )]`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function eraExpr(cal: CalendarSetting): string {
|
|
75
|
+
if (!cal.before) return cal.after;
|
|
76
|
+
return (
|
|
77
|
+
`[Select_CString( GreaterThanOrEqualTo_int32( '(int32)$YEAR|q$', '(int32)${cal.epoch}' ), ` +
|
|
78
|
+
`'${cstring(cal.after)}', '${cstring(cal.before)}' )]`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function locFile(lang: string, lines: string[]): string {
|
|
83
|
+
return [`l_${lang}:`, ...lines, ""].join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The two files implementing the calendar in game:
|
|
88
|
+
*
|
|
89
|
+
* - `localization/<lang>/px_calendar_l_<lang>.yml` - the era-math keys
|
|
90
|
+
* (new keys, so NOT in replace/).
|
|
91
|
+
* - `localization/replace/<lang>/px_calendar_dates_l_<lang>.yml` - the
|
|
92
|
+
* vanilla date-format overrides routing `$YEAR$` through them, plus the
|
|
93
|
+
* engine month-name overrides when the calendar has custom months.
|
|
94
|
+
*
|
|
95
|
+
* Deterministic filenames: regenerating after a `px.calendar` change
|
|
96
|
+
* overwrites the same two files.
|
|
97
|
+
*/
|
|
98
|
+
export function generateCalendarLoc(
|
|
99
|
+
cal: CalendarSetting,
|
|
100
|
+
spec: CalendarLocSpec,
|
|
101
|
+
lang: string
|
|
102
|
+
): CalendarLocResult {
|
|
103
|
+
const notes: string[] = [];
|
|
104
|
+
const header = [
|
|
105
|
+
" # Generated by 'Paradox: Generate Calendar Localization' from the px.calendar",
|
|
106
|
+
" # setting. Edit px.calendar and regenerate instead of editing by hand.",
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const eraDoc = cal.before
|
|
110
|
+
? ` # Script year >= ${cal.epoch} shows as (year - ${cal.epoch - 1}) ${cal.after}; below as (${cal.epoch} - year) ${cal.before}.`
|
|
111
|
+
: ` # Script year >= ${cal.epoch} shows as (year - ${cal.epoch - 1}) ${cal.after}; earlier years clamp to 1.`;
|
|
112
|
+
const mathLines = [
|
|
113
|
+
...header,
|
|
114
|
+
eraDoc,
|
|
115
|
+
` ${CAL_YEAR_KEY}:0 "${yearExpr(cal)}"`,
|
|
116
|
+
` ${CAL_ERA_KEY}:0 "${eraExpr(cal)}"`,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const overrideLines = [
|
|
120
|
+
...header,
|
|
121
|
+
" # Overrides of the engine's date-format keys: same formats as vanilla, with",
|
|
122
|
+
` # the raw $YEAR$ routed through ${CAL_YEAR_KEY}/${CAL_ERA_KEY}.`,
|
|
123
|
+
];
|
|
124
|
+
for (const [key, format] of Object.entries(spec.dateFormats)) {
|
|
125
|
+
const value = format.replace("{year}", `$${CAL_YEAR_KEY}$`).replace("{era}", `$${CAL_ERA_KEY}$`);
|
|
126
|
+
overrideLines.push(` ${key}:0 "${value}"`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (cal.months) {
|
|
130
|
+
const months = cal.months;
|
|
131
|
+
if (!spec.monthKeys) {
|
|
132
|
+
notes.push("This game's month-name keys are not mapped yet; custom month names were not generated.");
|
|
133
|
+
} else {
|
|
134
|
+
// The calendar carries the engine's twelve names (sanitizeCalendar); the
|
|
135
|
+
// spec lists the keys of the months it knows, first month first.
|
|
136
|
+
overrideLines.push(" # Engine month names (long and abbreviated forms both get the custom name).");
|
|
137
|
+
spec.monthKeys.forEach(([long, short], i) => {
|
|
138
|
+
const name = months[i].replace(/"/g, '\\"');
|
|
139
|
+
overrideLines.push(` ${long}:0 "${name}"`);
|
|
140
|
+
if (short !== long) overrideLines.push(` ${short}:0 "${name}"`);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
notes.push(
|
|
146
|
+
`Generated for '${lang}' only; other languages the mod ships need the same overrides in their replace folder.`
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
files: [
|
|
151
|
+
{ relPath: `localization/${lang}/px_calendar_l_${lang}.yml`, content: locFile(lang, mathLines) },
|
|
152
|
+
{
|
|
153
|
+
relPath: `localization/replace/${lang}/px_calendar_dates_l_${lang}.yml`,
|
|
154
|
+
content: locFile(lang, overrideLines),
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
notes,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/configDir.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toolkit's per-mod config dir: `<mod>/.px-toolkit/`, holding
|
|
3
|
+
* `workshop.json`, `schema.json`, `playset.json`, the tiger baseline, the GUI
|
|
4
|
+
* preview values and the Workshop listing folder. Mods created before 0.4.0
|
|
5
|
+
* have a per-game name instead (each GameMeta's `legacyConfigDirName`);
|
|
6
|
+
* reads keep finding it, and the first write renames it.
|
|
7
|
+
*
|
|
8
|
+
* No `vscode` imports: unit-tested in plain Node.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
|
|
13
|
+
export const PX_CONFIG_DIR = ".px-toolkit";
|
|
14
|
+
|
|
15
|
+
export interface ConfigDirNames {
|
|
16
|
+
configDirName: string;
|
|
17
|
+
/** The pre-0.4.0 per-game name, still read as a fallback. */
|
|
18
|
+
legacyConfigDirName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The config dir to READ from: the current name when it exists, else the
|
|
23
|
+
* legacy one when that exists, else the current name. Never touches disk.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveConfigDir(root: string, names: ConfigDirNames): string {
|
|
26
|
+
const current = path.join(root, names.configDirName);
|
|
27
|
+
if (!names.legacyConfigDirName || fs.existsSync(current)) return current;
|
|
28
|
+
const legacy = path.join(root, names.legacyConfigDirName);
|
|
29
|
+
return fs.existsSync(legacy) ? legacy : current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The config dir to WRITE to. Renames a legacy dir to the current name first;
|
|
34
|
+
* if the rename fails (locked file, read-only parent) the legacy dir stays in
|
|
35
|
+
* use so the write still lands where reads look.
|
|
36
|
+
*/
|
|
37
|
+
export function migrateConfigDir(root: string, names: ConfigDirNames): string {
|
|
38
|
+
const current = path.join(root, names.configDirName);
|
|
39
|
+
const resolved = resolveConfigDir(root, names);
|
|
40
|
+
if (resolved === current) return current;
|
|
41
|
+
try {
|
|
42
|
+
fs.renameSync(resolved, current);
|
|
43
|
+
return current;
|
|
44
|
+
} catch {
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Constants needed on both sides of the LSP boundary.
|
|
3
|
-
*/
|
|
4
|
-
import type { TokenKind } from "./types";
|
|
5
|
-
|
|
6
|
-
/** The script_docs log files and the token kind each one contributes. */
|
|
7
|
-
export const LOG_FILES: Array<{ file: string; kind: TokenKind }> = [
|
|
8
|
-
{ file: "triggers.log", kind: "trigger" },
|
|
9
|
-
{ file: "effects.log", kind: "effect" },
|
|
10
|
-
{ file: "event_targets.log", kind: "event_target" },
|
|
11
|
-
{ file: "modifiers.log", kind: "modifier" },
|
|
12
|
-
];
|
|
1
|
+
/**
|
|
2
|
+
* Constants needed on both sides of the LSP boundary.
|
|
3
|
+
*/
|
|
4
|
+
import type { TokenKind } from "./types";
|
|
5
|
+
|
|
6
|
+
/** The script_docs log files and the token kind each one contributes. */
|
|
7
|
+
export const LOG_FILES: Array<{ file: string; kind: TokenKind }> = [
|
|
8
|
+
{ file: "triggers.log", kind: "trigger" },
|
|
9
|
+
{ file: "effects.log", kind: "effect" },
|
|
10
|
+
{ file: "event_targets.log", kind: "event_target" },
|
|
11
|
+
{ file: "modifiers.log", kind: "modifier" },
|
|
12
|
+
];
|
|
@@ -1,101 +1,101 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reader and writer for the newer Paradox mod descriptor convention:
|
|
3
|
-
* `<mod>/.metadata/metadata.json` (newer titles) instead of the launcher
|
|
4
|
-
* `.mod` file. Fail-soft on read: any read/parse problem yields null.
|
|
5
|
-
*
|
|
6
|
-
* The field set is copied from three real workshop mods (2026-08-12: name, id,
|
|
7
|
-
* version, supported_game_version, tags, relationships, game_custom_data;
|
|
8
|
-
* `game_id` appears in one of the three and is left out here because the other
|
|
9
|
-
* two load without it). The relationship shape is the one the Community Mod
|
|
10
|
-
* Framework documents for the mods that depend on it.
|
|
11
|
-
*/
|
|
12
|
-
import * as fs from "fs";
|
|
13
|
-
import * as path from "path";
|
|
14
|
-
|
|
15
|
-
/** Mod-root-relative path of the descriptor, forward slashes. */
|
|
16
|
-
export const METADATA_REL_PATH = ".metadata/metadata.json";
|
|
17
|
-
|
|
18
|
-
/** One entry of `relationships`: a link to another mod. */
|
|
19
|
-
export interface MetadataRelationship {
|
|
20
|
-
/** "dependency", "incompatible_with", "load_before", "load_after". */
|
|
21
|
-
rel_type: string;
|
|
22
|
-
/** The other mod's `id` field (NOT its Workshop number). */
|
|
23
|
-
id: string;
|
|
24
|
-
/** Shown when the other mod is not on disk. */
|
|
25
|
-
display_name?: string;
|
|
26
|
-
/** Only "mod" is supported by the launcher today. */
|
|
27
|
-
resource_type: string;
|
|
28
|
-
/** Version of the other mod, `*` for any. */
|
|
29
|
-
version?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The fields of a mod's metadata.json this toolkit reads or writes. */
|
|
33
|
-
export interface ModMetadata {
|
|
34
|
-
name?: string;
|
|
35
|
-
id?: string;
|
|
36
|
-
version?: string;
|
|
37
|
-
supported_game_version?: string;
|
|
38
|
-
short_description?: string;
|
|
39
|
-
tags?: string[];
|
|
40
|
-
relationships?: MetadataRelationship[];
|
|
41
|
-
game_custom_data?: { multiplayer_synchronized?: boolean; replace_paths?: string[] };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** The parsed `<dir>/.metadata/metadata.json`, or null when absent/unreadable. */
|
|
45
|
-
export function readMetadata(dir: string): ModMetadata | null {
|
|
46
|
-
try {
|
|
47
|
-
const file = path.join(dir, ".metadata", "metadata.json");
|
|
48
|
-
if (!fs.existsSync(file)) return null;
|
|
49
|
-
return JSON.parse(fs.readFileSync(file, "utf8")) as ModMetadata;
|
|
50
|
-
} catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The mod's display name from `<dir>/.metadata/metadata.json`, or null. */
|
|
56
|
-
export function readMetadataName(dir: string): string | null {
|
|
57
|
-
const name = readMetadata(dir)?.name;
|
|
58
|
-
return typeof name === "string" && name.trim() !== "" ? name : null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** True when `dir` carries a metadata-style descriptor. */
|
|
62
|
-
export function hasMetadataDescriptor(dir: string): boolean {
|
|
63
|
-
try {
|
|
64
|
-
return fs.existsSync(path.join(dir, ".metadata", "metadata.json"));
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface MetadataScaffold {
|
|
71
|
-
name: string;
|
|
72
|
-
/** Stable identifier other mods point their relationships at. */
|
|
73
|
-
id: string;
|
|
74
|
-
/** The mod's own version, not the game's. */
|
|
75
|
-
version?: string;
|
|
76
|
-
/** Game version the mod is for, `*` when unknown. */
|
|
77
|
-
supportedGameVersion: string;
|
|
78
|
-
shortDescription?: string;
|
|
79
|
-
tags?: string[];
|
|
80
|
-
relationships?: MetadataRelationship[];
|
|
81
|
-
/** Vanilla folders the mod unloads wholesale (total conversions). */
|
|
82
|
-
replacePaths?: string[];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** A launcher-correct starter metadata.json, in the corpus's field order. */
|
|
86
|
-
export function scaffoldMetadata(opts: MetadataScaffold): string {
|
|
87
|
-
const body: ModMetadata = {
|
|
88
|
-
name: opts.name,
|
|
89
|
-
id: opts.id,
|
|
90
|
-
version: opts.version ?? "0.1.0",
|
|
91
|
-
supported_game_version: opts.supportedGameVersion,
|
|
92
|
-
...(opts.shortDescription ? { short_description: opts.shortDescription } : {}),
|
|
93
|
-
tags: opts.tags ?? [],
|
|
94
|
-
relationships: opts.relationships ?? [],
|
|
95
|
-
game_custom_data: {
|
|
96
|
-
multiplayer_synchronized: true,
|
|
97
|
-
...(opts.replacePaths && opts.replacePaths.length > 0 ? { replace_paths: opts.replacePaths } : {}),
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
return JSON.stringify(body, null, 2) + "\n";
|
|
101
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reader and writer for the newer Paradox mod descriptor convention:
|
|
3
|
+
* `<mod>/.metadata/metadata.json` (newer titles) instead of the launcher
|
|
4
|
+
* `.mod` file. Fail-soft on read: any read/parse problem yields null.
|
|
5
|
+
*
|
|
6
|
+
* The field set is copied from three real workshop mods (2026-08-12: name, id,
|
|
7
|
+
* version, supported_game_version, tags, relationships, game_custom_data;
|
|
8
|
+
* `game_id` appears in one of the three and is left out here because the other
|
|
9
|
+
* two load without it). The relationship shape is the one the Community Mod
|
|
10
|
+
* Framework documents for the mods that depend on it.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
|
|
15
|
+
/** Mod-root-relative path of the descriptor, forward slashes. */
|
|
16
|
+
export const METADATA_REL_PATH = ".metadata/metadata.json";
|
|
17
|
+
|
|
18
|
+
/** One entry of `relationships`: a link to another mod. */
|
|
19
|
+
export interface MetadataRelationship {
|
|
20
|
+
/** "dependency", "incompatible_with", "load_before", "load_after". */
|
|
21
|
+
rel_type: string;
|
|
22
|
+
/** The other mod's `id` field (NOT its Workshop number). */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Shown when the other mod is not on disk. */
|
|
25
|
+
display_name?: string;
|
|
26
|
+
/** Only "mod" is supported by the launcher today. */
|
|
27
|
+
resource_type: string;
|
|
28
|
+
/** Version of the other mod, `*` for any. */
|
|
29
|
+
version?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The fields of a mod's metadata.json this toolkit reads or writes. */
|
|
33
|
+
export interface ModMetadata {
|
|
34
|
+
name?: string;
|
|
35
|
+
id?: string;
|
|
36
|
+
version?: string;
|
|
37
|
+
supported_game_version?: string;
|
|
38
|
+
short_description?: string;
|
|
39
|
+
tags?: string[];
|
|
40
|
+
relationships?: MetadataRelationship[];
|
|
41
|
+
game_custom_data?: { multiplayer_synchronized?: boolean; replace_paths?: string[] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The parsed `<dir>/.metadata/metadata.json`, or null when absent/unreadable. */
|
|
45
|
+
export function readMetadata(dir: string): ModMetadata | null {
|
|
46
|
+
try {
|
|
47
|
+
const file = path.join(dir, ".metadata", "metadata.json");
|
|
48
|
+
if (!fs.existsSync(file)) return null;
|
|
49
|
+
return JSON.parse(fs.readFileSync(file, "utf8")) as ModMetadata;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The mod's display name from `<dir>/.metadata/metadata.json`, or null. */
|
|
56
|
+
export function readMetadataName(dir: string): string | null {
|
|
57
|
+
const name = readMetadata(dir)?.name;
|
|
58
|
+
return typeof name === "string" && name.trim() !== "" ? name : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when `dir` carries a metadata-style descriptor. */
|
|
62
|
+
export function hasMetadataDescriptor(dir: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
return fs.existsSync(path.join(dir, ".metadata", "metadata.json"));
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MetadataScaffold {
|
|
71
|
+
name: string;
|
|
72
|
+
/** Stable identifier other mods point their relationships at. */
|
|
73
|
+
id: string;
|
|
74
|
+
/** The mod's own version, not the game's. */
|
|
75
|
+
version?: string;
|
|
76
|
+
/** Game version the mod is for, `*` when unknown. */
|
|
77
|
+
supportedGameVersion: string;
|
|
78
|
+
shortDescription?: string;
|
|
79
|
+
tags?: string[];
|
|
80
|
+
relationships?: MetadataRelationship[];
|
|
81
|
+
/** Vanilla folders the mod unloads wholesale (total conversions). */
|
|
82
|
+
replacePaths?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A launcher-correct starter metadata.json, in the corpus's field order. */
|
|
86
|
+
export function scaffoldMetadata(opts: MetadataScaffold): string {
|
|
87
|
+
const body: ModMetadata = {
|
|
88
|
+
name: opts.name,
|
|
89
|
+
id: opts.id,
|
|
90
|
+
version: opts.version ?? "0.1.0",
|
|
91
|
+
supported_game_version: opts.supportedGameVersion,
|
|
92
|
+
...(opts.shortDescription ? { short_description: opts.shortDescription } : {}),
|
|
93
|
+
tags: opts.tags ?? [],
|
|
94
|
+
relationships: opts.relationships ?? [],
|
|
95
|
+
game_custom_data: {
|
|
96
|
+
multiplayer_synchronized: true,
|
|
97
|
+
...(opts.replacePaths && opts.replacePaths.length > 0 ? { replace_paths: opts.replacePaths } : {}),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return JSON.stringify(body, null, 2) + "\n";
|
|
101
|
+
}
|