@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/dist/protocol.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.scopeAtRequest = exports.dependenciesRequest = exports.eventValueOptionsRequest = exports.EVENT_VOCABULARY_MAX_VALUES = exports.EVENT_VOCABULARY_MAX_TOKENS = exports.eventVocabularyRequest = exports.eventBannerRequest = exports.eventGraphRequest = exports.guiWidgetEditRequest = exports.guiSourceEditRequest = exports.guiSaveValuesRequest = exports.GUI_PREVIEW_MAX = exports.guiPreviewRequest = exports.guiVocabularyRequest = exports.guiDependenciesRequest = exports.guiWidgetInfoRequest = exports.guiLayoutRequest = exports.guiTreeRequest = exports.eventDetailRequest = exports.overridesRequest = exports.locCoverageRequest = exports.modOverviewRequest = exports.progressNotification = exports.indexChangedNotification = exports.statusNotification = exports.lookupLocRequest = exports.indexStatsRequest = exports.reloadDocsRequest = exports.modFileChangedNotification = exports.configChangedNotification = exports.allClientCommandIds = exports.clientCommands = void 0;
3
+ exports.DYNASTY_SKILLS = exports.dynastyTreeRequest = exports.modifierFormatsRequest = exports.definitionEditRequest = exports.definitionFormRequest = exports.scopeAtRequest = exports.snippetsRequest = exports.dependenciesRequest = exports.eventValueOptionsRequest = exports.DEFINITION_FORM_MAX_EXAMPLE = exports.DEFINITION_FORM_MAX_SAMPLED = exports.EVENT_VOCABULARY_MAX_VALUES = exports.EVENT_VOCABULARY_MAX_TOKENS = exports.eventVocabularyRequest = exports.eventBannerRequest = exports.eventGraphRequest = exports.guiWidgetEditRequest = exports.guiSourceEditRequest = exports.guiSaveValuesRequest = exports.GUI_PREVIEW_MAX = exports.guiPreviewRequest = exports.guiVocabularyRequest = exports.guiDependenciesRequest = exports.guiWidgetInfoRequest = exports.guiLayoutRequest = exports.guiTreeRequest = exports.exampleWikiEntryRequest = exports.exampleWikiVocabularyKinds = exports.exampleWikiVariableKinds = exports.exampleWikiRequest = exports.eventDetailRequest = exports.overridesRequest = exports.locCoverageRequest = exports.modOverviewRequest = exports.progressNotification = exports.indexChangedNotification = exports.statusNotification = exports.locTextRequest = exports.lookupLocRequest = exports.indexStatsRequest = exports.reloadDocsRequest = exports.modFileChangedNotification = exports.configChangedNotification = exports.allClientCommandIds = exports.clientCommands = void 0;
4
4
  // ---- client command ids ----------------------------------------------------
5
5
  /**
6
6
  * Client commands the server references in code actions and hover links (part
@@ -14,6 +14,7 @@ exports.clientCommands = {
14
14
  editLocalization: "px.editLocalization",
15
15
  openLocalizationSideBySide: "px.openLocalizationSideBySide",
16
16
  showReferences: "px.showReferences",
17
+ showExamplesWiki: "px.showExamplesWiki",
17
18
  };
18
19
  /** Every id in {@link clientCommands}: what a fully capable client registers. */
19
20
  exports.allClientCommandIds = Object.values(exports.clientCommands);
@@ -29,6 +30,24 @@ exports.indexStatsRequest = "paradox/indexStats";
29
30
  /** Request: look up localization entries for a key; {@link LookupLocParams} -> {@link LocEntryInfo}[].
30
31
  * Mod entries shadow vanilla ones (the full list is returned, mod first). */
31
32
  exports.lookupLocRequest = "paradox/lookupLoc";
33
+ /**
34
+ * Request: a localization value as the PLAYER reads it;
35
+ * {@link LocTextParams} -> {@link LocTextResult}.
36
+ *
37
+ * {@link lookupLocRequest} answers the value verbatim, which is what an editor
38
+ * needs. A panel that SHOWS the value needs the sentence: the games write a
39
+ * culture parameter as `"The [GetTrait('rough_terrain_expert').GetName(
40
+ * GetNullCharacter )] Commander Trait is more common"` (145 of the 280
41
+ * parameter values with a real call take that one shape), and a modder reading
42
+ * a form must not be shown the brackets.
43
+ *
44
+ * Everything the renderer knows is DERIVED: the words come from the loc index
45
+ * (mod entries shadow the game's), the kind a `Get<Something>('name')` chain
46
+ * names comes from the definition index, and the loc key that kind's names take
47
+ * comes from the active profile's schema. No table of function names, so a
48
+ * workspace of any of the games gets the same behavior from its own schema.
49
+ */
50
+ exports.locTextRequest = "paradox/locText";
32
51
  // ---- server -> client ------------------------------------------------------
33
52
  /** Notification: data health for the status bar; payload {@link StatusPayload}. */
34
53
  exports.statusNotification = "paradox/status";
@@ -48,12 +67,46 @@ exports.locCoverageRequest = "paradox/locCoverage";
48
67
  exports.overridesRequest = "paradox/overrides";
49
68
  /** Request: full event detail for the graph inspector; {@link EventDetailParams} -> {@link EventDetail} | null. */
50
69
  exports.eventDetailRequest = "paradox/eventDetail";
70
+ /**
71
+ * Request: the searchable catalog behind the Examples Wiki;
72
+ * `null` -> {@link ExampleWikiIndex}.
73
+ *
74
+ * One compact row per name the server knows about, so a client can filter and
75
+ * rank the whole vocabulary without asking again. Everything expensive (the
76
+ * full documentation, the usage block, the vanilla sites) is left to
77
+ * {@link exampleWikiEntryRequest}.
78
+ */
79
+ exports.exampleWikiRequest = "paradox/exampleWiki";
80
+ /** The {@link ExampleWikiKind}s whose rows come from the definition index. */
81
+ exports.exampleWikiVariableKinds = [
82
+ "variable",
83
+ "local_variable",
84
+ "global_variable",
85
+ "variable_list",
86
+ "local_variable_list",
87
+ "global_variable_list",
88
+ "list",
89
+ ];
90
+ /**
91
+ * The {@link ExampleWikiKind}s whose rows are script grammar rather than a
92
+ * name from a dump or an index. One filter chip covers both.
93
+ */
94
+ exports.exampleWikiVocabularyKinds = ["keyword", "scope_word"];
95
+ /**
96
+ * Request: everything the toolkit knows about ONE Examples Wiki row;
97
+ * {@link ExampleWikiEntryParams} -> {@link ExampleWikiDetail} | null.
98
+ *
99
+ * `null` means the name is not in the catalog. Vanilla example sites are
100
+ * searched on demand and come back as absolute paths, so a client can open
101
+ * the file at the line without resolving anything itself.
102
+ */
103
+ exports.exampleWikiEntryRequest = "paradox/exampleWikiEntry";
51
104
  /** Request: GUI widget tree for a .gui document; {@link GuiTreeParams} -> {@link GuiTree}. */
52
105
  exports.guiTreeRequest = "paradox/guiTree";
53
106
  /**
54
107
  * Request: rendered GUI layout for a .gui document;
55
108
  * {@link GuiLayoutParams} -> {@link GuiLayoutResult}. Rectangles come from
56
- * the measured layout engine (docs/gui-designer/calibration/spec.md), with
109
+ * the measured layout engine (docs/gui-designer/spec.md), with
57
110
  * templates/types resolved against the vanilla + mod gui tree.
58
111
  */
59
112
  exports.guiLayoutRequest = "paradox/guiLayout";
@@ -169,6 +222,20 @@ exports.eventVocabularyRequest = "paradox/eventVocabulary";
169
222
  /** Caps: an editor lists a page at a time, and these ride on every open. */
170
223
  exports.EVENT_VOCABULARY_MAX_TOKENS = 600;
171
224
  exports.EVENT_VOCABULARY_MAX_VALUES = 400;
225
+ /**
226
+ * Most values {@link DefinitionFormKey.sampled} carries, and the point past
227
+ * which a key is taken to have no value SET at all (a key whose value differs
228
+ * per definition is a free field, not a list to offer).
229
+ */
230
+ exports.DEFINITION_FORM_MAX_SAMPLED = 80;
231
+ /**
232
+ * How long a block body {@link DefinitionFormKey.example} may be. A placeholder
233
+ * is read at a glance, and the shortest bodies a game writes for a block key
234
+ * (a trait's `triggered_opinion`, a culture's `parameters`) fit well inside
235
+ * this; a longer one is cut with an ellipsis rather than dropped, because half
236
+ * a real body still says what the key wants.
237
+ */
238
+ exports.DEFINITION_FORM_MAX_EXAMPLE = 120;
172
239
  /**
173
240
  * Request: the value set a VALUE belongs to, resolved through the definition
174
241
  * index; {@link EventValueOptionsParams} -> {@link EventValueOptionsResult} |
@@ -187,6 +254,23 @@ exports.eventValueOptionsRequest = "paradox/eventValueOptions";
187
254
  * (uri + position) or by name (optionally disambiguated by kind).
188
255
  */
189
256
  exports.dependenciesRequest = "paradox/dependencies";
257
+ /**
258
+ * Request: the code snippets a host can offer for one open script document;
259
+ * {@link SnippetsParams} -> {@link SnippetsResult}. Answers for OPEN script
260
+ * documents only (the server reads the client's text, not the disk); a document
261
+ * it does not know answers with an EMPTY list, never an error.
262
+ *
263
+ * Two sources, neither hand-written. The definition and child-block skeletons
264
+ * are the measured shape of the document folder's own definition kind (at least
265
+ * half of the game's definitions of that kind carry each key, in the median
266
+ * order they hold there). The token entries are the block form of the `usage:`
267
+ * example the game's own script_docs dump ships for an engine trigger or effect,
268
+ * filtered to the block the cursor sits in.
269
+ *
270
+ * Every entry carries BOTH insert forms, exactly like completion does: `snippet`
271
+ * for a host that expands `${1:…}` tabstops, `plain` for one that does not.
272
+ */
273
+ exports.snippetsRequest = "paradox/snippets";
190
274
  /**
191
275
  * Request: the inferred scope chain at a cursor position;
192
276
  * {@link ScopeAtParams} -> {@link ScopeAtResult} | null. Answers for OPEN
@@ -199,3 +283,76 @@ exports.dependenciesRequest = "paradox/dependencies";
199
283
  * asserts more than the derived link tables actually say.
200
284
  */
201
285
  exports.scopeAtRequest = "paradox/scopeAt";
286
+ // ---- content creators --------------------------------------------------------
287
+ /**
288
+ * Request: everything a visual creator needs to draw a form for one definition
289
+ * kind; {@link DefinitionFormParams} -> {@link DefinitionForm} | null (null =
290
+ * the active game's schema has no such kind, which is the honest answer for a
291
+ * client asking about content this game does not have).
292
+ *
293
+ * Nothing in the answer is hand-written for the creator: the folder, the loc
294
+ * key patterns and the icon folder come from the schema table, the keys from
295
+ * the harvested `_*.info` structures, the option lists from the definition
296
+ * index (the same resolver {@link eventValueOptionsRequest} answers with) and
297
+ * `existing` from the same index walk {@link modOverviewRequest} does. A game
298
+ * patch that adds a key or a value changes the form without a release.
299
+ */
300
+ exports.definitionFormRequest = "paradox/definitionForm";
301
+ /**
302
+ * Request: text edits that write a definition into a script file;
303
+ * {@link DefinitionEditParams} -> {@link DefinitionEditResult}. The script
304
+ * sibling of {@link guiSourceEditRequest}, over the same span model, and with
305
+ * the same division of labour: the server never writes, it returns offsets
306
+ * into the text it was handed and the host applies them as ONE
307
+ * `WorkspaceEdit`, which keeps undo and dirty state in the editor.
308
+ *
309
+ * Offsets are UTF-16 into `params.text` (the document text, with no BOM, the
310
+ * way an editor delivers it), computed against that one text and applied
311
+ * end-first. Every edit is surgical, so a file's other definitions, its
312
+ * comments, its CRLF and its indentation stay byte-identical.
313
+ */
314
+ exports.definitionEditRequest = "paradox/definitionEdit";
315
+ /**
316
+ * Request: how the GAME prints each modifier; {@link ModifierFormatsParams} ->
317
+ * {@link ModifierFormatsResult} | null (null = the active profile names no
318
+ * formats source, or the game folder is not configured).
319
+ *
320
+ * A creator that lets a modder add `monthly_income = 0.5` has to show what the
321
+ * player will see, and the player sees "[gold_i] +0.50 Monthly Income" in
322
+ * green. None of that is written here: the flags come from the game's own
323
+ * `common/modifier_definition_formats/` (documented by `_definitions.info`
324
+ * there), every word comes from the loc index, and every icon comes from the
325
+ * `texticon` blocks of the game's `gui/texticons.gui`. A modifier no format
326
+ * block names gets the file's documented defaults, so the answer covers every
327
+ * modifier token the server knows rather than only the formatted ones.
328
+ */
329
+ exports.modifierFormatsRequest = "paradox/modifierFormats";
330
+ /**
331
+ * Request: a dynasty as a family tree; {@link DynastyTreeParams} ->
332
+ * {@link DynastyTreeResult}.
333
+ *
334
+ * Two answers behind one method. Without `dynasty` the result is the picker
335
+ * list: every dynasty the index knows, mod entries first. With `dynasty` it is
336
+ * that dynasty's houses and members, read out of the game's own
337
+ * `history/characters` files.
338
+ *
339
+ * Everything is DERIVED: the folders come from the active profile's schema
340
+ * (`dynasty`, `dynasty_house`, `character` kinds), the members from the
341
+ * character blocks themselves, the display names from the loc index. A profile
342
+ * whose schema has no `dynasty` kind answers `supported: false` and empty
343
+ * lists, which is what a client shows instead of an empty tree.
344
+ */
345
+ exports.dynastyTreeRequest = "paradox/dynastyTree";
346
+ /**
347
+ * The character-level skill keys, in the order a client shows them. MEASURED
348
+ * over the vanilla `history/characters` corpus (2026-09-03): stewardship 8 964,
349
+ * martial 8 940, diplomacy 8 908, intrigue 8 892, learning 495, prowess 150.
350
+ */
351
+ exports.DYNASTY_SKILLS = [
352
+ "diplomacy",
353
+ "martial",
354
+ "stewardship",
355
+ "intrigue",
356
+ "learning",
357
+ "prowess",
358
+ ];
@@ -0,0 +1,40 @@
1
+ /** Title/description pair of one Workshop language. Absent field = not translated. */
2
+ export interface WorkshopTranslation {
3
+ title?: string;
4
+ description?: string;
5
+ }
6
+ /** The fields of `workshop.json` this toolkit reads or writes. */
7
+ export interface WorkshopMeta {
8
+ /** Workshop item id (decimal string), for the games whose descriptor has no field for it. */
9
+ publishedFileId?: string;
10
+ /** The item's description in the default language, BBCode as Steam renders it. */
11
+ description?: string;
12
+ /** Keyed by Steam API language code (`german`, `schinese`, ...), never the default language. */
13
+ translations?: Record<string, WorkshopTranslation>;
14
+ }
15
+ /** The parsed `<configDir>/workshop.json`, or null when absent/unreadable. */
16
+ export declare function readWorkshopMeta(configDir: string): WorkshopMeta | null;
17
+ /**
18
+ * Merge `patch` into the record and write it back. Unknown keys of the file
19
+ * survive; a patch key set to `undefined` is left as it was. `translations`
20
+ * replaces as a whole (the caller edits the full map).
21
+ */
22
+ export declare function upsertWorkshopMeta(configDir: string, patch: WorkshopMeta): void;
23
+ /**
24
+ * The languages the Workshop accepts item text in: Steam's API language codes
25
+ * with their English names, in Steam's documented order.
26
+ * https://partner.steamgames.com/doc/store/localization/languages
27
+ */
28
+ export declare const STEAM_LANGUAGES: readonly {
29
+ api: string;
30
+ label: string;
31
+ }[];
32
+ /** English name of a Steam API language code; the code itself when unknown. */
33
+ export declare function steamLanguageLabel(api: string): string;
34
+ /**
35
+ * Steam API language code for a Paradox localization folder language
36
+ * (`translationCore.ts` LOC_LANGUAGES), or null when Steam has no counterpart.
37
+ * The two vocabularies differ where Steam's codes predate its own store pages
38
+ * (`koreana`, `schinese`).
39
+ */
40
+ export declare function steamLanguageForLoc(locLanguage: string): string | null;
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.STEAM_LANGUAGES = void 0;
37
+ exports.readWorkshopMeta = readWorkshopMeta;
38
+ exports.upsertWorkshopMeta = upsertWorkshopMeta;
39
+ exports.steamLanguageLabel = steamLanguageLabel;
40
+ exports.steamLanguageForLoc = steamLanguageForLoc;
41
+ /**
42
+ * The mod's local Workshop record: `<mod>/<configDir>/workshop.json`. It holds
43
+ * what the game's own descriptor has no field for - the item's description and
44
+ * its per-language translations (title + description shown to Workshop
45
+ * visitors browsing Steam in that language), plus the published id for the
46
+ * `.metadata` games (launcher-`.mod` games keep the id in `remote_file_id`).
47
+ *
48
+ * The local file is the canonical copy once the user manages the item from the
49
+ * toolkit: uploads read from here, and "fetch" pulls the live values down into
50
+ * it. Reads and writes are merge-preserving: keys this version does not know
51
+ * survive a round trip.
52
+ *
53
+ * No `vscode` imports here: this module is unit-tested in plain Node.
54
+ */
55
+ const fs = __importStar(require("fs"));
56
+ const path = __importStar(require("path"));
57
+ /** The parsed `<configDir>/workshop.json`, or null when absent/unreadable. */
58
+ function readWorkshopMeta(configDir) {
59
+ try {
60
+ const raw = JSON.parse(fs.readFileSync(path.join(configDir, "workshop.json"), "utf8"));
61
+ if (typeof raw !== "object" || raw === null)
62
+ return null;
63
+ return raw;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Merge `patch` into the record and write it back. Unknown keys of the file
71
+ * survive; a patch key set to `undefined` is left as it was. `translations`
72
+ * replaces as a whole (the caller edits the full map).
73
+ */
74
+ function upsertWorkshopMeta(configDir, patch) {
75
+ const file = path.join(configDir, "workshop.json");
76
+ const current = (readWorkshopMeta(configDir) ?? {});
77
+ for (const [key, value] of Object.entries(patch)) {
78
+ if (value !== undefined)
79
+ current[key] = value;
80
+ }
81
+ fs.mkdirSync(path.dirname(file), { recursive: true });
82
+ fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n", "utf8");
83
+ }
84
+ /**
85
+ * The languages the Workshop accepts item text in: Steam's API language codes
86
+ * with their English names, in Steam's documented order.
87
+ * https://partner.steamgames.com/doc/store/localization/languages
88
+ */
89
+ exports.STEAM_LANGUAGES = [
90
+ { api: "arabic", label: "Arabic" },
91
+ { api: "bulgarian", label: "Bulgarian" },
92
+ { api: "schinese", label: "Chinese (Simplified)" },
93
+ { api: "tchinese", label: "Chinese (Traditional)" },
94
+ { api: "czech", label: "Czech" },
95
+ { api: "danish", label: "Danish" },
96
+ { api: "dutch", label: "Dutch" },
97
+ { api: "english", label: "English" },
98
+ { api: "finnish", label: "Finnish" },
99
+ { api: "french", label: "French" },
100
+ { api: "german", label: "German" },
101
+ { api: "greek", label: "Greek" },
102
+ { api: "hungarian", label: "Hungarian" },
103
+ { api: "indonesian", label: "Indonesian" },
104
+ { api: "italian", label: "Italian" },
105
+ { api: "japanese", label: "Japanese" },
106
+ { api: "koreana", label: "Korean" },
107
+ { api: "norwegian", label: "Norwegian" },
108
+ { api: "polish", label: "Polish" },
109
+ { api: "portuguese", label: "Portuguese" },
110
+ { api: "brazilian", label: "Portuguese (Brazil)" },
111
+ { api: "romanian", label: "Romanian" },
112
+ { api: "russian", label: "Russian" },
113
+ { api: "spanish", label: "Spanish (Spain)" },
114
+ { api: "latam", label: "Spanish (Latin America)" },
115
+ { api: "swedish", label: "Swedish" },
116
+ { api: "thai", label: "Thai" },
117
+ { api: "turkish", label: "Turkish" },
118
+ { api: "ukrainian", label: "Ukrainian" },
119
+ { api: "vietnamese", label: "Vietnamese" },
120
+ ];
121
+ /** English name of a Steam API language code; the code itself when unknown. */
122
+ function steamLanguageLabel(api) {
123
+ return exports.STEAM_LANGUAGES.find((l) => l.api === api)?.label ?? api;
124
+ }
125
+ /**
126
+ * Steam API language code for a Paradox localization folder language
127
+ * (`translationCore.ts` LOC_LANGUAGES), or null when Steam has no counterpart.
128
+ * The two vocabularies differ where Steam's codes predate its own store pages
129
+ * (`koreana`, `schinese`).
130
+ */
131
+ function steamLanguageForLoc(locLanguage) {
132
+ const map = {
133
+ english: "english",
134
+ french: "french",
135
+ german: "german",
136
+ spanish: "spanish",
137
+ russian: "russian",
138
+ korean: "koreana",
139
+ simp_chinese: "schinese",
140
+ japanese: "japanese",
141
+ polish: "polish",
142
+ braz_por: "brazilian",
143
+ turkish: "turkish",
144
+ };
145
+ return map[locLanguage] ?? null;
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@px-lsp/protocol",
3
- "version": "0.1.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/arrays.ts CHANGED
@@ -1,16 +1,16 @@
1
- /** Array helpers shared by the server and the client. */
2
-
3
- /**
4
- * Append every element of `source` to `target`.
5
- *
6
- * `target.push(...source)` passes one argument per element and throws
7
- * `RangeError: Maximum call stack size exceeded` past ~125k elements (measured,
8
- * node 24, default stack). On the index paths that is a size-triggered crash:
9
- * one engine/vanilla root already carries ~460k definitions and one generated
10
- * mod file can carry six figures on its own. The loop has no ceiling and
11
- * measures the same as the spread (2M elements appended in 10k pieces: 23 ms
12
- * loop vs 22 ms spread; as one 500k piece: 5.5 ms loop vs 9.6 ms spread).
13
- */
14
- export function pushAll<T>(target: T[], source: readonly T[]): void {
15
- for (let i = 0; i < source.length; i++) target.push(source[i]);
16
- }
1
+ /** Array helpers shared by the server and the client. */
2
+
3
+ /**
4
+ * Append every element of `source` to `target`.
5
+ *
6
+ * `target.push(...source)` passes one argument per element and throws
7
+ * `RangeError: Maximum call stack size exceeded` past ~125k elements (measured,
8
+ * node 24, default stack). On the index paths that is a size-triggered crash:
9
+ * one engine/vanilla root already carries ~460k definitions and one generated
10
+ * mod file can carry six figures on its own. The loop has no ceiling and
11
+ * measures the same as the spread (2M elements appended in 10k pieces: 23 ms
12
+ * loop vs 22 ms spread; as one 500k piece: 5.5 ms loop vs 9.6 ms spread).
13
+ */
14
+ export function pushAll<T>(target: T[], source: readonly T[]): void {
15
+ for (let i = 0; i < source.length; i++) target.push(source[i]);
16
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Custom calendar display: total-conversion mods (AGoT, LotR, Hegemonia...)
3
+ * keep script dates on the engine's single increasing year axis but *display*
4
+ * them on their own era system, e.g. epoch 4000 means script year 3000 shows
5
+ * as "1000 BC" and 4000 as "1 AD". The mapping cannot be detected from mod
6
+ * files, so the mod declares it once in `px.calendar` (workspace settings,
7
+ * committed with the mod).
8
+ *
9
+ * Pure logic, no `vscode` imports: shared by the language server (inlay hints,
10
+ * hover) and the extension (the Insert Date command), unit-tested in plain
11
+ * Node.
12
+ */
13
+
14
+ export interface CalendarSetting {
15
+ /** Script year displayed as year 1 of the `after` era (no year zero). */
16
+ epoch: number;
17
+ /** Era label for script years >= epoch ("AD", "TA"...). */
18
+ after: string;
19
+ /** Era label for script years < epoch ("BC"). Omitted = single-era
20
+ * calendar: years before the epoch get no display form. */
21
+ before?: string;
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[];
30
+ }
31
+
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
+ "January",
37
+ "February",
38
+ "March",
39
+ "April",
40
+ "May",
41
+ "June",
42
+ "July",
43
+ "August",
44
+ "September",
45
+ "October",
46
+ "November",
47
+ "December",
48
+ ];
49
+
50
+ /** The month names a date reads with: the mod's twelve, or the engine's. */
51
+ export function monthNames(cal: CalendarSetting): string[] {
52
+ return cal.months ?? GREGORIAN_MONTHS;
53
+ }
54
+
55
+ /**
56
+ * Validate a calendar straight out of JSON settings (any client, any hand-
57
+ * edited settings file). Returns a clean copy, or undefined when the value is
58
+ * not a usable calendar - features then behave as if none was configured.
59
+ */
60
+ export function sanitizeCalendar(raw: unknown): CalendarSetting | undefined {
61
+ if (typeof raw !== "object" || raw === null) return undefined;
62
+ const o = raw as Record<string, unknown>;
63
+ const epoch = o.epoch;
64
+ const after = o.after;
65
+ if (typeof epoch !== "number" || !Number.isInteger(epoch) || epoch < 1) return undefined;
66
+ if (typeof after !== "string" || after.trim() === "") return undefined;
67
+ const cal: CalendarSetting = { epoch, after: after.trim() };
68
+ if (typeof o.before === "string" && o.before.trim() !== "") cal.before = o.before.trim();
69
+ // Typed input picks the era by its label and the month by its name, so a
70
+ // collision would resolve silently to the wrong one: not a usable calendar.
71
+ if (cal.before && cal.before.toLowerCase() === cal.after.toLowerCase()) return undefined;
72
+ if (Array.isArray(o.months) && o.months.length > 0) {
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[] = [];
78
+ const seen = new Set<string>();
79
+ for (const m of o.months) {
80
+ const name = typeof m === "string" ? m : ((m as Record<string, unknown> | null)?.name ?? null);
81
+ if (typeof name !== "string" || name.trim() === "") return undefined;
82
+ const key = name.trim().toLowerCase();
83
+ if (seen.has(key)) return undefined;
84
+ seen.add(key);
85
+ months.push(name.trim());
86
+ }
87
+ cal.months = months;
88
+ }
89
+ return cal;
90
+ }
91
+
92
+ /** A script-file date token (`3000.1.1`), or null when the text is not one. */
93
+ export function parseScriptDate(text: string): { y: number; m: number; d: number } | null {
94
+ const match = /^(\d{1,5})\.(\d{1,2})\.(\d{1,2})$/.exec(text);
95
+ if (!match) return null;
96
+ return { y: Number(match[1]), m: Number(match[2]), d: Number(match[3]) };
97
+ }
98
+
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];
102
+ }
103
+
104
+ /** Era-mapped year: "1000 BC". Null for pre-epoch years of a single-era calendar. */
105
+ export function displayYear(cal: CalendarSetting, y: number): string | null {
106
+ if (y >= cal.epoch) return `${y - cal.epoch + 1} ${cal.after}`;
107
+ return cal.before ? `${cal.epoch - y} ${cal.before}` : null;
108
+ }
109
+
110
+ /**
111
+ * Full display form of a script date: "1000 BC" for the year's first day
112
+ * (how start dates read in game), "15 March 1000 BC" otherwise. Null when the
113
+ * date does not fit the calendar.
114
+ */
115
+ export function displayDate(cal: CalendarSetting, y: number, m: number, d: number): string | null {
116
+ if (!isValidScriptDate(y, m, d)) return null;
117
+ const year = displayYear(cal, y);
118
+ if (!year) return null;
119
+ if (m === 1 && d === 1) return year;
120
+ return `${d} ${monthNames(cal)[m - 1]} ${year}`;
121
+ }
122
+
123
+ export type ConvertResult = { ok: true; script: string; display: string } | { ok: false; error: string };
124
+
125
+ /** Case-insensitive month lookup: exact name, else unique prefix. */
126
+ function monthByName(cal: CalendarSetting, text: string): number | null {
127
+ const needle = text.toLowerCase();
128
+ const months = monthNames(cal);
129
+ const exact = months.findIndex((m) => m.toLowerCase() === needle);
130
+ if (exact >= 0) return exact + 1;
131
+ const prefixed = months.map((m, i) => ({ m, i })).filter(({ m }) => m.toLowerCase().startsWith(needle));
132
+ return prefixed.length === 1 ? prefixed[0].i + 1 : null;
133
+ }
134
+
135
+ /**
136
+ * A display-calendar date typed by the user -> the script date the file needs.
137
+ * Grammar: `YEAR [ERA] [MONTH [DAY]]`, e.g. "1000 BC", "1000 BC March 15",
138
+ * "1000 BC 3 15", "1 AD", "500" (era defaults to `after`). MONTH is a number
139
+ * or a month name (unique prefix is enough).
140
+ */
141
+ export function convertDisplayInput(cal: CalendarSetting, input: string): ConvertResult {
142
+ const words = input.trim().split(/\s+/).filter(Boolean);
143
+ if (words.length === 0) return { ok: false, error: "type a year, e.g. 1000 " + (cal.before ?? cal.after) };
144
+ if (!/^\d{1,5}$/.test(words[0])) return { ok: false, error: `"${words[0]}" is not a year` };
145
+ const year = Number(words[0]);
146
+ let rest = words.slice(1);
147
+
148
+ let era = cal.after;
149
+ if (rest.length > 0) {
150
+ const w = rest[0].toLowerCase();
151
+ if (w === cal.after.toLowerCase()) {
152
+ rest = rest.slice(1);
153
+ } else if (cal.before && w === cal.before.toLowerCase()) {
154
+ era = cal.before;
155
+ rest = rest.slice(1);
156
+ }
157
+ }
158
+
159
+ let m = 1;
160
+ let d = 1;
161
+ if (rest.length > 0) {
162
+ const month = /^\d{1,2}$/.test(rest[0]) ? Number(rest[0]) : monthByName(cal, rest[0]);
163
+ if (month === null) return { ok: false, error: `"${rest[0]}" is not a month of this calendar` };
164
+ m = month;
165
+ rest = rest.slice(1);
166
+ }
167
+ if (rest.length > 0) {
168
+ if (!/^\d{1,3}$/.test(rest[0])) return { ok: false, error: `"${rest[0]}" is not a day` };
169
+ d = Number(rest[0]);
170
+ rest = rest.slice(1);
171
+ }
172
+ if (rest.length > 0) return { ok: false, error: `unexpected "${rest.join(" ")}"` };
173
+
174
+ if (year < 1) return { ok: false, error: "years start at 1 (no year zero)" };
175
+ const y = era === cal.after ? year + cal.epoch - 1 : cal.epoch - year;
176
+ if (y < 1) return { ok: false, error: `${year} ${era} is before script year 1 (epoch ${cal.epoch})` };
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` };
181
+ }
182
+ return { ok: true, script: `${y}.${m}.${d}`, display: displayDate(cal, y, m, d)! };
183
+ }