@px-lsp/protocol 0.2.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.
@@ -12,16 +12,14 @@ export interface WorkshopMeta {
12
12
  /** Keyed by Steam API language code (`german`, `schinese`, ...), never the default language. */
13
13
  translations?: Record<string, WorkshopTranslation>;
14
14
  }
15
- /** Mod-root-relative path of the record, forward slashes. */
16
- export declare function workshopMetaRelPath(configDirName: string): string;
17
- /** The parsed `<dir>/<configDir>/workshop.json`, or null when absent/unreadable. */
18
- export declare function readWorkshopMeta(dir: string, configDirName: string): WorkshopMeta | null;
15
+ /** The parsed `<configDir>/workshop.json`, or null when absent/unreadable. */
16
+ export declare function readWorkshopMeta(configDir: string): WorkshopMeta | null;
19
17
  /**
20
18
  * Merge `patch` into the record and write it back. Unknown keys of the file
21
19
  * survive; a patch key set to `undefined` is left as it was. `translations`
22
20
  * replaces as a whole (the caller edits the full map).
23
21
  */
24
- export declare function upsertWorkshopMeta(dir: string, configDirName: string, patch: WorkshopMeta): void;
22
+ export declare function upsertWorkshopMeta(configDir: string, patch: WorkshopMeta): void;
25
23
  /**
26
24
  * The languages the Workshop accepts item text in: Steam's API language codes
27
25
  * with their English names, in Steam's documented order.
@@ -34,7 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.STEAM_LANGUAGES = void 0;
37
- exports.workshopMetaRelPath = workshopMetaRelPath;
38
37
  exports.readWorkshopMeta = readWorkshopMeta;
39
38
  exports.upsertWorkshopMeta = upsertWorkshopMeta;
40
39
  exports.steamLanguageLabel = steamLanguageLabel;
@@ -55,14 +54,10 @@ exports.steamLanguageForLoc = steamLanguageForLoc;
55
54
  */
56
55
  const fs = __importStar(require("fs"));
57
56
  const path = __importStar(require("path"));
58
- /** Mod-root-relative path of the record, forward slashes. */
59
- function workshopMetaRelPath(configDirName) {
60
- return `${configDirName}/workshop.json`;
61
- }
62
- /** The parsed `<dir>/<configDir>/workshop.json`, or null when absent/unreadable. */
63
- function readWorkshopMeta(dir, configDirName) {
57
+ /** The parsed `<configDir>/workshop.json`, or null when absent/unreadable. */
58
+ function readWorkshopMeta(configDir) {
64
59
  try {
65
- const raw = JSON.parse(fs.readFileSync(path.join(dir, configDirName, "workshop.json"), "utf8"));
60
+ const raw = JSON.parse(fs.readFileSync(path.join(configDir, "workshop.json"), "utf8"));
66
61
  if (typeof raw !== "object" || raw === null)
67
62
  return null;
68
63
  return raw;
@@ -76,9 +71,9 @@ function readWorkshopMeta(dir, configDirName) {
76
71
  * survive; a patch key set to `undefined` is left as it was. `translations`
77
72
  * replaces as a whole (the caller edits the full map).
78
73
  */
79
- function upsertWorkshopMeta(dir, configDirName, patch) {
80
- const file = path.join(dir, configDirName, "workshop.json");
81
- const current = (readWorkshopMeta(dir, configDirName) ?? {});
74
+ function upsertWorkshopMeta(configDir, patch) {
75
+ const file = path.join(configDir, "workshop.json");
76
+ const current = (readWorkshopMeta(configDir) ?? {});
82
77
  for (const [key, value] of Object.entries(patch)) {
83
78
  if (value !== undefined)
84
79
  current[key] = value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@px-lsp/protocol",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "description": "Wire contract (custom LSP requests/notifications, settings types) and shared helpers for the px-lsp language server and its clients.",
6
6
  "keywords": [
package/src/calendar.ts CHANGED
@@ -11,13 +11,6 @@
11
11
  * Node.
12
12
  */
13
13
 
14
- export interface CalendarMonth {
15
- /** Display name ("March", "Narvinye"). */
16
- name: string;
17
- /** Day count; the engine has no leap years, so one number per month. */
18
- days: number;
19
- }
20
-
21
14
  export interface CalendarSetting {
22
15
  /** Script year displayed as year 1 of the `after` era (no year zero). */
23
16
  epoch: number;
@@ -26,13 +19,20 @@ export interface CalendarSetting {
26
19
  /** Era label for script years < epoch ("BC"). Omitted = single-era
27
20
  * calendar: years before the epoch get no display form. */
28
21
  before?: string;
29
- /** Custom month names and day counts, first month first. Omitted = the
30
- * standard 12 months (Feb 28: the engine has no leap years). */
31
- months?: CalendarMonth[];
22
+ /**
23
+ * The engine's twelve months under the mod's own names, first month first.
24
+ * Omitted = January to December. Only the NAMES are the mod's: the game has
25
+ * twelve months of fixed length (31 28 31 30 31 30 31 31 30 31 30 31, no
26
+ * leap years) and no script can change that, so a date's month and day are
27
+ * always the engine's and only read differently.
28
+ */
29
+ months?: string[];
32
30
  }
33
31
 
34
- const DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
35
- export const GREGORIAN_MONTHS: CalendarMonth[] = [
32
+ /** Days per engine month; what a script date's day is bounded by. */
33
+ export const ENGINE_MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
34
+
35
+ export const GREGORIAN_MONTHS: string[] = [
36
36
  "January",
37
37
  "February",
38
38
  "March",
@@ -45,9 +45,10 @@ export const GREGORIAN_MONTHS: CalendarMonth[] = [
45
45
  "October",
46
46
  "November",
47
47
  "December",
48
- ].map((name, i) => ({ name, days: DAYS[i] }));
48
+ ];
49
49
 
50
- export function monthsOf(cal: CalendarSetting): CalendarMonth[] {
50
+ /** The month names a date reads with: the mod's twelve, or the engine's. */
51
+ export function monthNames(cal: CalendarSetting): string[] {
51
52
  return cal.months ?? GREGORIAN_MONTHS;
52
53
  }
53
54
 
@@ -69,17 +70,19 @@ export function sanitizeCalendar(raw: unknown): CalendarSetting | undefined {
69
70
  // collision would resolve silently to the wrong one: not a usable calendar.
70
71
  if (cal.before && cal.before.toLowerCase() === cal.after.toLowerCase()) return undefined;
71
72
  if (Array.isArray(o.months) && o.months.length > 0) {
72
- const months: CalendarMonth[] = [];
73
+ // Exactly the engine's twelve. A name may still arrive as the older
74
+ // `{ name, days }` object; its day count never meant anything to the
75
+ // game and is dropped.
76
+ if (o.months.length !== GREGORIAN_MONTHS.length) return undefined;
77
+ const months: string[] = [];
73
78
  const seen = new Set<string>();
74
79
  for (const m of o.months) {
75
- if (typeof m !== "object" || m === null) return undefined;
76
- const { name, days } = m as Record<string, unknown>;
80
+ const name = typeof m === "string" ? m : ((m as Record<string, unknown> | null)?.name ?? null);
77
81
  if (typeof name !== "string" || name.trim() === "") return undefined;
78
- if (typeof days !== "number" || !Number.isInteger(days) || days < 1 || days > 999) return undefined;
79
82
  const key = name.trim().toLowerCase();
80
83
  if (seen.has(key)) return undefined;
81
84
  seen.add(key);
82
- months.push({ name: name.trim(), days });
85
+ months.push(name.trim());
83
86
  }
84
87
  cal.months = months;
85
88
  }
@@ -93,10 +96,9 @@ export function parseScriptDate(text: string): { y: number; m: number; d: number
93
96
  return { y: Number(match[1]), m: Number(match[2]), d: Number(match[3]) };
94
97
  }
95
98
 
96
- /** Month/day within the calendar's bounds (year just has to be positive). */
97
- export function isValidScriptDate(cal: CalendarSetting, y: number, m: number, d: number): boolean {
98
- const months = monthsOf(cal);
99
- return y >= 1 && m >= 1 && m <= months.length && d >= 1 && d <= months[m - 1].days;
99
+ /** A date the engine reads: a positive year, one of its twelve months, a day that month has. */
100
+ export function isValidScriptDate(y: number, m: number, d: number): boolean {
101
+ return y >= 1 && m >= 1 && m <= ENGINE_MONTH_DAYS.length && d >= 1 && d <= ENGINE_MONTH_DAYS[m - 1];
100
102
  }
101
103
 
102
104
  /** Era-mapped year: "1000 BC". Null for pre-epoch years of a single-era calendar. */
@@ -111,11 +113,11 @@ export function displayYear(cal: CalendarSetting, y: number): string | null {
111
113
  * date does not fit the calendar.
112
114
  */
113
115
  export function displayDate(cal: CalendarSetting, y: number, m: number, d: number): string | null {
114
- if (!isValidScriptDate(cal, y, m, d)) return null;
116
+ if (!isValidScriptDate(y, m, d)) return null;
115
117
  const year = displayYear(cal, y);
116
118
  if (!year) return null;
117
119
  if (m === 1 && d === 1) return year;
118
- return `${d} ${monthsOf(cal)[m - 1].name} ${year}`;
120
+ return `${d} ${monthNames(cal)[m - 1]} ${year}`;
119
121
  }
120
122
 
121
123
  export type ConvertResult = { ok: true; script: string; display: string } | { ok: false; error: string };
@@ -123,12 +125,10 @@ export type ConvertResult = { ok: true; script: string; display: string } | { ok
123
125
  /** Case-insensitive month lookup: exact name, else unique prefix. */
124
126
  function monthByName(cal: CalendarSetting, text: string): number | null {
125
127
  const needle = text.toLowerCase();
126
- const months = monthsOf(cal);
127
- const exact = months.findIndex((m) => m.name.toLowerCase() === needle);
128
+ const months = monthNames(cal);
129
+ const exact = months.findIndex((m) => m.toLowerCase() === needle);
128
130
  if (exact >= 0) return exact + 1;
129
- const prefixed = months
130
- .map((m, i) => ({ m, i }))
131
- .filter(({ m }) => m.name.toLowerCase().startsWith(needle));
131
+ const prefixed = months.map((m, i) => ({ m, i })).filter(({ m }) => m.toLowerCase().startsWith(needle));
132
132
  return prefixed.length === 1 ? prefixed[0].i + 1 : null;
133
133
  }
134
134
 
@@ -174,11 +174,10 @@ export function convertDisplayInput(cal: CalendarSetting, input: string): Conver
174
174
  if (year < 1) return { ok: false, error: "years start at 1 (no year zero)" };
175
175
  const y = era === cal.after ? year + cal.epoch - 1 : cal.epoch - year;
176
176
  if (y < 1) return { ok: false, error: `${year} ${era} is before script year 1 (epoch ${cal.epoch})` };
177
- if (!isValidScriptDate(cal, y, m, d)) {
178
- const months = monthsOf(cal);
179
- return m >= 1 && m <= months.length
180
- ? { ok: false, error: `${months[m - 1].name} has ${months[m - 1].days} days` }
181
- : { ok: false, error: `this calendar has ${months.length} months` };
177
+ if (!isValidScriptDate(y, m, d)) {
178
+ return m >= 1 && m <= ENGINE_MONTH_DAYS.length
179
+ ? { ok: false, error: `${monthNames(cal)[m - 1]} has ${ENGINE_MONTH_DAYS[m - 1]} days` }
180
+ : { ok: false, error: `the game has ${ENGINE_MONTH_DAYS.length} months` };
182
181
  }
183
182
  return { ok: true, script: `${y}.${m}.${d}`, display: displayDate(cal, y, m, d)! };
184
183
  }
@@ -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
+ }
@@ -10,7 +10,6 @@
10
10
  * from the GameProfile, so no game knowledge lives here.
11
11
  */
12
12
  import type { CalendarSetting } from "./calendar";
13
- import { monthsOf } from "./calendar";
14
13
 
15
14
  /**
16
15
  * The loc keys a game formats dates through, verified against the game's
@@ -128,24 +127,18 @@ export function generateCalendarLoc(
128
127
  }
129
128
 
130
129
  if (cal.months) {
131
- const months = monthsOf(cal);
130
+ const months = cal.months;
132
131
  if (!spec.monthKeys) {
133
132
  notes.push("This game's month-name keys are not mapped yet; custom month names were not generated.");
134
- } else if (months.length !== spec.monthKeys.length) {
135
- notes.push(
136
- `The engine has exactly ${spec.monthKeys.length} months; your calendar declares ${months.length}, ` +
137
- "so month names were not generated (the month count itself cannot be modded)."
138
- );
139
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.
140
136
  overrideLines.push(" # Engine month names (long and abbreviated forms both get the custom name).");
141
- months.forEach((m, i) => {
142
- const [long, short] = spec.monthKeys![i];
143
- overrideLines.push(` ${long}:0 "${m.name.replace(/"/g, '\\"')}"`);
144
- if (short !== long) overrideLines.push(` ${short}:0 "${m.name.replace(/"/g, '\\"')}"`);
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}"`);
145
141
  });
146
- notes.push(
147
- "Month day counts are engine-fixed (31/28/31...); custom day counts only affect the editor's display."
148
- );
149
142
  }
150
143
  }
151
144
 
@@ -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
+ }