@px-lsp/server 0.3.0 → 0.3.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.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/data/ck3/skeletons.json +1 -0
  3. package/data/eu5/skeletons.json +1 -0
  4. package/data/vic3/skeletons.json +1 -0
  5. package/dist/browser-data/ck3/docs.json +1 -1
  6. package/dist/browser-data/ck3/tokens.json +1 -1
  7. package/dist/browser-data/vic3/tokens.json +1 -1
  8. package/dist/browser.js +50 -37
  9. package/dist/server.js +2000 -352
  10. package/dist/types/packages/server/src/features/blockSnippets.d.ts +20 -4
  11. package/dist/types/packages/server/src/features/completion.d.ts +11 -0
  12. package/dist/types/packages/server/src/features/definitionSkeletons.d.ts +24 -0
  13. package/dist/types/packages/server/src/games/ck3/schema.d.ts +1 -1
  14. package/dist/types/packages/server/src/games/eu5/index.d.ts +1 -1
  15. package/dist/types/packages/server/src/games/profile.d.ts +85 -1
  16. package/dist/types/packages/server/src/schema/skeletons.d.ts +102 -0
  17. package/dist/types/packages/server/src/schema/types.d.ts +30 -0
  18. package/package.json +3 -3
  19. package/src/coa/coa.ts +31 -0
  20. package/src/coa/coaDesigner.ts +131 -0
  21. package/src/coa/coaParse.ts +3 -0
  22. package/src/creators/definitionEdit.ts +198 -0
  23. package/src/creators/definitionForm.ts +479 -0
  24. package/src/creators/modifierFormats.ts +0 -0
  25. package/src/data/docsParser.ts +21 -4
  26. package/src/features/blockSnippets.ts +151 -20
  27. package/src/features/calendarDates.ts +8 -5
  28. package/src/features/completion.ts +38 -4
  29. package/src/features/definitionSkeletons.ts +109 -0
  30. package/src/features/inlayHints.ts +5 -2
  31. package/src/features/locText.ts +223 -0
  32. package/src/features/snippetList.ts +83 -0
  33. package/src/games/ck3/index.ts +16 -0
  34. package/src/games/ck3/meta.ts +81 -1
  35. package/src/games/ck3/schema.ts +83 -7
  36. package/src/games/ck3/structures.ts +37 -0
  37. package/src/games/eu5/index.ts +7 -1
  38. package/src/games/eu5/meta.ts +5 -1
  39. package/src/games/profile.ts +77 -1
  40. package/src/games/vic3/index.ts +4 -0
  41. package/src/games/vic3/meta.ts +5 -1
  42. package/src/gui/sourceModel.ts +51 -8
  43. package/src/gui/textResolve.ts +3 -3
  44. package/src/overview/dynastyTree.ts +493 -0
  45. package/src/overview/eventGraph.ts +21 -1
  46. package/src/overview/eventVocabulary.ts +33 -33
  47. package/src/overview/exampleWiki.ts +4 -1
  48. package/src/schema/loader.ts +2 -1
  49. package/src/schema/skeletons.ts +182 -0
  50. package/src/schema/types.ts +30 -0
  51. package/src/server.ts +147 -6
@@ -0,0 +1,493 @@
1
+ /**
2
+ * paradox/dynastyTree: a dynasty as a family tree, read out of the game's own
3
+ * files. `common/dynasties` and `common/dynasty_houses` give the names, and
4
+ * `history/characters` gives the members with their parents, spouses and dates.
5
+ *
6
+ * Which keys a character block carries is MEASURED, not remembered. Counted
7
+ * over the 207 vanilla files of `<game>/history/characters` on the install the
8
+ * repo's dev-paths point at (2026-09-03, 71 142 blocks), keys at the
9
+ * character's own level: name 71 138, culture 71 082, religion 70 078,
10
+ * dynasty 56 738, father 55 961, trait 28 690, mother 14 911, female 12 520,
11
+ * dynasty_house 10 426, stewardship 8 964, martial 8 940, diplomacy 8 908,
12
+ * intrigue 8 892, learning 495, dna 438, prowess 150. `birth` (56 451) and `death`
13
+ * (56 465) are NOT character-level keys: they sit inside a dated
14
+ * `880.1.1 = { … }` block, which is also where `add_spouse` lives (6 811 of
15
+ * 6 816). So the date of a birth is the KEY of the block that carries it.
16
+ * Values: `name` is a quoted plain string (never a loc key), `female = yes`
17
+ * (12 229) with `no` spelled out 68 times, `birth = yes` or `birth = "date"`,
18
+ * `death` additionally as a block (`death = { death_reason = … }`).
19
+ *
20
+ * The whole character corpus is parsed ONCE per index revision, because a
21
+ * dynasty's members cannot be found without reading every file: the link points
22
+ * from the character to the dynasty, never back. Measured on that same install
23
+ * (71 142 characters, 10 338 dynasties, 17.4 MB): 0.8 s for the first
24
+ * request, 12 ms for the next, 1 ms for one dynasty, 62 MB retained.
25
+ */
26
+ import * as fs from "fs";
27
+ import { DYNASTY_SKILLS } from "@px-lsp/protocol/protocol";
28
+ import type {
29
+ DynastyCharacter,
30
+ DynastyHouse,
31
+ DynastySummary,
32
+ DynastyTreeParams,
33
+ DynastyTreeResult,
34
+ } from "@px-lsp/protocol/protocol";
35
+ import type { DefSource, Definition } from "@px-lsp/protocol/types";
36
+ import type { ServerData } from "../serverData";
37
+ import { activeProfile } from "../games/active";
38
+ import { decode, LineIndex, parseScript, type BlockNode, type Statement } from "../parser";
39
+
40
+ /** Definition kinds this view is built from; each must be in the profile schema. */
41
+ const DYNASTY_KIND = "dynasty";
42
+ const HOUSE_KIND = "dynasty_house";
43
+ const CHARACTER_KIND = "character";
44
+
45
+ /** Script is last-in-wins, and a mod's file beats a parent's beats vanilla's. */
46
+ const SOURCE_RANK: Record<DefSource, number> = { mod: 2, parent: 1, vanilla: 0 };
47
+
48
+ /** A dated block key: the game writes `880.1.1 = { birth = yes }`. */
49
+ const DATE_RE = /^-?\d+\.\d+\.\d+$/;
50
+
51
+ const SKILL_KEYS: ReadonlySet<string> = new Set(DYNASTY_SKILLS);
52
+
53
+ interface FileRef {
54
+ file: string;
55
+ source: DefSource;
56
+ }
57
+
58
+ interface DynastyRecord {
59
+ id: string;
60
+ nameKey: string;
61
+ culture?: string;
62
+ source: DefSource;
63
+ file: string;
64
+ line: number;
65
+ }
66
+
67
+ interface HouseRecord extends DynastyRecord {
68
+ dynasty: string;
69
+ }
70
+
71
+ /** A character exactly as its block spells it; `external` is added per request. */
72
+ type CharacterRecord = Omit<DynastyCharacter, "external">;
73
+
74
+ /**
75
+ * Vanilla holds 71 142 characters, and the model is kept for as long as the
76
+ * index revision lasts, so the repeated scalars are shared instead of stored
77
+ * 71 142 times: 71 082 `culture` values are drawn from ~130 cultures, and the
78
+ * empty trait and spouse lists are one array between them. Measured on the
79
+ * vanilla install, this is the difference between 73 MB and 62 MB retained.
80
+ */
81
+ const NONE: readonly string[] = [];
82
+ type Intern = (value: string) => string;
83
+ function interner(): Intern {
84
+ const pool = new Map<string, string>();
85
+ return (value) => {
86
+ const known = pool.get(value);
87
+ if (known !== undefined) return known;
88
+ pool.set(value, value);
89
+ return value;
90
+ };
91
+ }
92
+
93
+ interface DynastyModel {
94
+ revision: number;
95
+ dynasties: Map<string, DynastyRecord>;
96
+ houses: Map<string, HouseRecord>;
97
+ characters: Map<string, CharacterRecord>;
98
+ /** Dynasty id -> member ids, in file order. Houses resolve to their dynasty. */
99
+ members: Map<string, string[]>;
100
+ nextCharacterId: string;
101
+ nextDynastyId: string;
102
+ }
103
+
104
+ let cached: DynastyModel | null = null;
105
+
106
+ /**
107
+ * How long a model outlives the last request that used it. The panel is opened
108
+ * for a while and then closed, and 62 MB is too much to hold for the life of
109
+ * the server for a view nobody is looking at; a rebuild is a second's work.
110
+ */
111
+ const IDLE_RELEASE_MS = 10 * 60 * 1000;
112
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
113
+
114
+ /** Test hook: forget the cached model (the server invalidates by revision). */
115
+ export function clearDynastyModel(): void {
116
+ cached = null;
117
+ if (idleTimer) {
118
+ clearTimeout(idleTimer);
119
+ idleTimer = null;
120
+ }
121
+ }
122
+
123
+ /** True while a model is held; the release path is what a test watches. */
124
+ export function hasDynastyModel(): boolean {
125
+ return cached !== null;
126
+ }
127
+
128
+ /** Restart the idle clock: the model lives ten minutes past its last reader. */
129
+ function keepAlive(): void {
130
+ if (idleTimer) clearTimeout(idleTimer);
131
+ idleTimer = setTimeout(clearDynastyModel, IDLE_RELEASE_MS);
132
+ // A pending release must never hold the process open.
133
+ idleTimer.unref?.();
134
+ }
135
+
136
+ function blockOf(stmt: Statement): BlockNode | null {
137
+ if (stmt.kind !== "assignment") return null;
138
+ const v = stmt.value;
139
+ if (v?.kind === "block") return v;
140
+ if (v?.kind === "tagged-block") return v.block;
141
+ return null;
142
+ }
143
+
144
+ function scalarOf(stmt: Statement): string | null {
145
+ if (stmt.kind !== "assignment") return null;
146
+ return stmt.value?.kind === "scalar" ? stmt.value.text : null;
147
+ }
148
+
149
+ /**
150
+ * One character block into a record. Keys the form does not model (nicknames,
151
+ * effects, claims) are left where they are: this view reads, and the writer
152
+ * preserves them from the source span.
153
+ */
154
+ export function readCharacterBlock(
155
+ id: string,
156
+ block: BlockNode,
157
+ where: { source: DefSource; file: string; line: number },
158
+ intern: Intern = (v) => v
159
+ ): CharacterRecord {
160
+ const traits: string[] = [];
161
+ const spouses: string[] = [];
162
+ const out: CharacterRecord = { id, name: id, female: false, traits, spouses, ...where };
163
+ for (const stmt of block.statements) {
164
+ if (stmt.kind !== "assignment") continue;
165
+ const key = stmt.key.text;
166
+ if (DATE_RE.test(key)) {
167
+ const dated = blockOf(stmt);
168
+ if (!dated) continue;
169
+ for (const inner of dated.statements) {
170
+ if (inner.kind !== "assignment") continue;
171
+ const ikey = inner.key.text;
172
+ // A dated block dates its own statements: `birth`/`death` may say `yes`
173
+ // or repeat the date, and `death = { … }` carries a reason.
174
+ if (ikey === "birth") out.birth ??= key;
175
+ else if (ikey === "death") out.death ??= key;
176
+ else if (ikey === "add_spouse") {
177
+ const spouse = scalarOf(inner);
178
+ if (spouse) spouses.push(intern(spouse));
179
+ }
180
+ }
181
+ continue;
182
+ }
183
+ const value = scalarOf(stmt);
184
+ if (value === null) continue;
185
+ if (SKILL_KEYS.has(key)) {
186
+ // A skill the game rolls is not written at all, so a value that is not a
187
+ // number (a script value, `@my_martial`) is left to the writer's verbatim
188
+ // path rather than shown as an editable number the form cannot round trip.
189
+ const number = Number(value);
190
+ if (Number.isFinite(number)) (out.skills ??= {})[key] = number;
191
+ continue;
192
+ }
193
+ switch (key) {
194
+ case "name":
195
+ out.name = value;
196
+ break;
197
+ case "female":
198
+ out.female = value === "yes";
199
+ break;
200
+ case "dynasty":
201
+ out.dynasty = intern(value);
202
+ break;
203
+ case "dynasty_house":
204
+ out.house = intern(value);
205
+ break;
206
+ case "father":
207
+ out.father = value;
208
+ break;
209
+ case "mother":
210
+ out.mother = value;
211
+ break;
212
+ case "culture":
213
+ out.culture = intern(value);
214
+ break;
215
+ case "religion":
216
+ out.religion = intern(value);
217
+ break;
218
+ case "dna":
219
+ // Written both bare and quoted; the parser hands over the text either
220
+ // way, so the record carries the NAME and never the quotes.
221
+ out.dna = value;
222
+ break;
223
+ case "trait":
224
+ traits.push(intern(value));
225
+ break;
226
+ case "add_spouse":
227
+ // Rare (5 of 6 816) but legal at the character's own level.
228
+ spouses.push(intern(value));
229
+ break;
230
+ }
231
+ }
232
+ if (traits.length === 0) out.traits = NONE as string[];
233
+ if (spouses.length === 0) out.spouses = NONE as string[];
234
+ return out;
235
+ }
236
+
237
+ /** `common/dynasties` and `common/dynasty_houses` share a block shape. */
238
+ function readDefinitionBlock(
239
+ id: string,
240
+ block: BlockNode,
241
+ where: { source: DefSource; file: string; line: number }
242
+ ): HouseRecord {
243
+ const out: HouseRecord = { id, nameKey: "", dynasty: "", ...where };
244
+ for (const stmt of block.statements) {
245
+ const value = scalarOf(stmt);
246
+ if (value === null || stmt.kind !== "assignment") continue;
247
+ if (stmt.key.text === "name") out.nameKey = value;
248
+ else if (stmt.key.text === "culture") out.culture = value;
249
+ else if (stmt.key.text === "dynasty") out.dynasty = value;
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /** Read a file and hand every top-level `name = { … }` block to `onBlock`. */
255
+ function eachTopLevelBlock(
256
+ file: string,
257
+ onBlock: (name: string, block: BlockNode, line: number) => void
258
+ ): void {
259
+ let text: string;
260
+ try {
261
+ text = decode(fs.readFileSync(file)).text;
262
+ } catch {
263
+ return; // deleted between the index scan and this read
264
+ }
265
+ const { root } = parseScript(text);
266
+ const li = new LineIndex(text);
267
+ for (const stmt of root.statements) {
268
+ if (stmt.kind !== "assignment") continue;
269
+ const block = blockOf(stmt);
270
+ if (!block) continue;
271
+ onBlock(stmt.key.text, block, li.positionAt(stmt.key.range.start).line);
272
+ }
273
+ }
274
+
275
+ /** Files holding definitions of each kind, plus the largest numeric id seen. */
276
+ function scanIndex(data: ServerData): {
277
+ files: Map<string, FileRef[]>;
278
+ maxCharacterId: number;
279
+ maxDynastyId: number;
280
+ } {
281
+ const files = new Map<string, FileRef[]>([
282
+ [DYNASTY_KIND, []],
283
+ [HOUSE_KIND, []],
284
+ [CHARACTER_KIND, []],
285
+ ]);
286
+ const seen = new Set<string>();
287
+ let maxCharacterId = 0;
288
+ let maxDynastyId = 0;
289
+ const bump = (def: Definition): void => {
290
+ const n = Number(def.name);
291
+ if (!Number.isInteger(n)) return;
292
+ if (def.kind === CHARACTER_KIND) maxCharacterId = Math.max(maxCharacterId, n);
293
+ else maxDynastyId = Math.max(maxDynastyId, n);
294
+ };
295
+ for (const def of data.index.allDefinitions()) {
296
+ const bucket = files.get(def.kind);
297
+ if (!bucket) continue;
298
+ bump(def);
299
+ const key = `${def.kind}${def.file}`;
300
+ if (seen.has(key)) continue;
301
+ seen.add(key);
302
+ bucket.push({ file: def.file, source: def.source });
303
+ }
304
+ return { files, maxCharacterId, maxDynastyId };
305
+ }
306
+
307
+ /** Later definitions win, and a higher source rank wins over any earlier one. */
308
+ function keep<T extends { source: DefSource }>(map: Map<string, T>, id: string, rec: T): void {
309
+ const prev = map.get(id);
310
+ if (prev && SOURCE_RANK[prev.source] > SOURCE_RANK[rec.source]) return;
311
+ map.set(id, rec);
312
+ }
313
+
314
+ function buildModel(data: ServerData): DynastyModel {
315
+ const { files, maxCharacterId, maxDynastyId } = scanIndex(data);
316
+ const dynasties = new Map<string, DynastyRecord>();
317
+ const houses = new Map<string, HouseRecord>();
318
+ const characters = new Map<string, CharacterRecord>();
319
+
320
+ // Vanilla shadows first so a mod's redefinition of the same id replaces it.
321
+ const ordered = (kind: string): FileRef[] =>
322
+ [...(files.get(kind) ?? [])].sort((a, b) => SOURCE_RANK[a.source] - SOURCE_RANK[b.source]);
323
+
324
+ for (const ref of ordered(DYNASTY_KIND)) {
325
+ eachTopLevelBlock(ref.file, (id, block, line) => {
326
+ keep(dynasties, id, readDefinitionBlock(id, block, { source: ref.source, file: ref.file, line }));
327
+ });
328
+ }
329
+ for (const ref of ordered(HOUSE_KIND)) {
330
+ eachTopLevelBlock(ref.file, (id, block, line) => {
331
+ keep(houses, id, readDefinitionBlock(id, block, { source: ref.source, file: ref.file, line }));
332
+ });
333
+ }
334
+ const intern = interner();
335
+ for (const ref of ordered(CHARACTER_KIND)) {
336
+ eachTopLevelBlock(ref.file, (id, block, line) => {
337
+ keep(
338
+ characters,
339
+ id,
340
+ readCharacterBlock(id, block, { source: ref.source, file: ref.file, line }, intern)
341
+ );
342
+ });
343
+ }
344
+
345
+ const members = new Map<string, string[]>();
346
+ for (const char of characters.values()) {
347
+ const dynasty = char.dynasty ?? (char.house ? houses.get(char.house)?.dynasty : undefined);
348
+ if (!dynasty) continue;
349
+ const list = members.get(dynasty);
350
+ if (list) list.push(char.id);
351
+ else members.set(dynasty, [char.id]);
352
+ }
353
+
354
+ return {
355
+ revision: data.index.revision,
356
+ dynasties,
357
+ houses,
358
+ characters,
359
+ members,
360
+ nextCharacterId: String(maxCharacterId + 1),
361
+ nextDynastyId: String(maxDynastyId + 1),
362
+ };
363
+ }
364
+
365
+ /**
366
+ * The model for the current index revision, built on first use and reused
367
+ * until the index moves on or nothing has asked for it in {@link IDLE_RELEASE_MS}.
368
+ */
369
+ function modelFor(data: ServerData): DynastyModel {
370
+ keepAlive();
371
+ if (cached && cached.revision === data.index.revision) return cached;
372
+ cached = buildModel(data);
373
+ return cached;
374
+ }
375
+
376
+ /** Loc text for a key, or the key itself: the panel shows what it can resolve. */
377
+ function locText(data: ServerData, key: string): string {
378
+ if (key === "") return "";
379
+ return data.index.lookup(key).find((d) => d.kind === "loc_key" && d.value !== undefined)?.value ?? key;
380
+ }
381
+
382
+ function summaryOf(
383
+ data: ServerData,
384
+ model: DynastyModel,
385
+ rec: DynastyRecord,
386
+ houseCount: number
387
+ ): DynastySummary {
388
+ return {
389
+ id: rec.id,
390
+ nameKey: rec.nameKey,
391
+ name: locText(data, rec.nameKey),
392
+ culture: rec.culture,
393
+ source: rec.source,
394
+ file: rec.file,
395
+ line: rec.line,
396
+ characterCount: model.members.get(rec.id)?.length ?? 0,
397
+ houseCount,
398
+ };
399
+ }
400
+
401
+ export function computeDynastyTree(
402
+ data: ServerData,
403
+ params: DynastyTreeParams,
404
+ inFocus: (file: string) => boolean = () => true
405
+ ): DynastyTreeResult {
406
+ // Gate on profile DATA: a game whose schema has no dynasties has no tree.
407
+ const supported = activeProfile().schema.some((entry) => entry.kind === DYNASTY_KIND);
408
+ if (!supported) return { supported: false, dynasties: [] };
409
+
410
+ const model = modelFor(data);
411
+ const visible = (rec: { source: DefSource; file: string }): boolean =>
412
+ rec.source !== "mod" || inFocus(rec.file);
413
+
414
+ const houseCounts = new Map<string, number>();
415
+ for (const house of model.houses.values()) {
416
+ if (!visible(house)) continue;
417
+ houseCounts.set(house.dynasty, (houseCounts.get(house.dynasty) ?? 0) + 1);
418
+ }
419
+
420
+ const wanted = params.dynasty;
421
+ if (wanted === undefined) {
422
+ const mod: DynastySummary[] = [];
423
+ const rest: DynastySummary[] = [];
424
+ for (const rec of model.dynasties.values()) {
425
+ if (!visible(rec)) continue;
426
+ const summary = summaryOf(data, model, rec, houseCounts.get(rec.id) ?? 0);
427
+ (rec.source === "mod" ? mod : rest).push(summary);
428
+ }
429
+ const byName = (a: DynastySummary, b: DynastySummary): number => a.name.localeCompare(b.name);
430
+ mod.sort(byName);
431
+ rest.sort(byName);
432
+ return {
433
+ supported: true,
434
+ dynasties: [...mod, ...rest],
435
+ nextCharacterId: model.nextCharacterId,
436
+ nextDynastyId: model.nextDynastyId,
437
+ };
438
+ }
439
+
440
+ const rec = model.dynasties.get(wanted);
441
+ if (!rec) {
442
+ return {
443
+ supported: true,
444
+ dynasties: [],
445
+ nextCharacterId: model.nextCharacterId,
446
+ nextDynastyId: model.nextDynastyId,
447
+ };
448
+ }
449
+ const houses: DynastyHouse[] = [];
450
+ for (const house of model.houses.values()) {
451
+ if (house.dynasty !== wanted || !visible(house)) continue;
452
+ houses.push({
453
+ id: house.id,
454
+ nameKey: house.nameKey,
455
+ name: locText(data, house.nameKey),
456
+ dynasty: house.dynasty,
457
+ source: house.source,
458
+ file: house.file,
459
+ line: house.line,
460
+ });
461
+ }
462
+ houses.sort((a, b) => a.name.localeCompare(b.name));
463
+
464
+ const characters: DynastyCharacter[] = [];
465
+ const taken = new Set<string>();
466
+ for (const id of model.members.get(wanted) ?? []) {
467
+ const char = model.characters.get(id);
468
+ if (!char || !visible(char) || taken.has(id)) continue;
469
+ taken.add(id);
470
+ characters.push(char);
471
+ }
472
+ // A parent or a spouse from another dynasty is drawn, but the tree is not
473
+ // theirs: without them a marriage renders as a node married to nothing.
474
+ for (const char of [...characters]) {
475
+ for (const other of [char.father, char.mother, ...char.spouses]) {
476
+ if (!other || taken.has(other)) continue;
477
+ const rel = model.characters.get(other);
478
+ if (!rel) continue;
479
+ taken.add(other);
480
+ characters.push({ ...rel, external: true });
481
+ }
482
+ }
483
+
484
+ return {
485
+ supported: true,
486
+ dynasties: [],
487
+ dynasty: summaryOf(data, model, rec, houses.length),
488
+ houses,
489
+ characters,
490
+ nextCharacterId: model.nextCharacterId,
491
+ nextDynastyId: model.nextDynastyId,
492
+ };
493
+ }
@@ -378,6 +378,23 @@ export function computeEventGraph(
378
378
  }
379
379
  labelEdges(data, graphEdges, sites);
380
380
 
381
+ // Connected only (the default): a definition no edge touches is dropped here,
382
+ // BEFORE the card facts are read, since that read is the expensive part.
383
+ // The queried root stays so a lone event still answers its own query.
384
+ let pruned = 0;
385
+ if (params.connectedOnly ?? true) {
386
+ const linked = new Set<string>();
387
+ for (const e of graphEdges) {
388
+ linked.add(e.from);
389
+ linked.add(e.to);
390
+ }
391
+ for (const id of [...selected]) {
392
+ if (linked.has(id) || id === params.root) continue;
393
+ selected.delete(id);
394
+ pruned++;
395
+ }
396
+ }
397
+
381
398
  // What each node fires, for the card's third line. Free: the edges are here.
382
399
  const firesCount = new Map<string, number>();
383
400
  for (const e of graphEdges) firesCount.set(e.from, (firesCount.get(e.from) ?? 0) + 1);
@@ -425,7 +442,10 @@ export function computeEventGraph(
425
442
  nodes.sort((a, b) => a.id.localeCompare(b.id));
426
443
  const graph: EventGraph = { nodes, edges: graphEdges, truncated, suggestions: suggestionsOf(vocabulary) };
427
444
  if (nodes.length === 0) {
428
- const reason = emptyReason(data, params, inFocus);
445
+ const reason =
446
+ pruned > 0
447
+ ? `${pruned} definition(s) are here but none is connected to another. Turn "Connected only" off to show them.`
448
+ : emptyReason(data, params, inFocus);
429
449
  if (reason) graph.emptyReason = reason;
430
450
  }
431
451
  return graph;
@@ -31,7 +31,7 @@ import type { KeySpec } from "../schema/types";
31
31
  /** One line is what a menu row shows; the rest is noise at that size. */
32
32
  const MAX_DOC = 220;
33
33
 
34
- function short(doc: string | undefined): string | undefined {
34
+ export function short(doc: string | undefined): string | undefined {
35
35
  if (!doc) return undefined;
36
36
  const oneLine = doc.replace(/\s+/g, " ").trim();
37
37
  if (oneLine === "") return undefined;
@@ -56,6 +56,36 @@ function keyItems(specs: Map<string, KeySpec> | undefined): EventVocabularyItem[
56
56
  }));
57
57
  }
58
58
 
59
+ /**
60
+ * Every indexed definition of one kind as offerable values, mod entries first
61
+ * and each side name-sorted, capped. The ONE place a dropdown's option list
62
+ * comes from: the event vocabulary, `paradox/eventValueOptions` and the
63
+ * creators' `paradox/definitionForm` all list a kind through this, so they can
64
+ * never disagree about what the index holds.
65
+ */
66
+ export function definitionsOfKind(
67
+ data: ServerData,
68
+ kind: string,
69
+ inFocus: (file: string) => boolean = () => true
70
+ ): EventVocabularyItem[] {
71
+ const seen = new Set<string>();
72
+ const mod: EventVocabularyItem[] = [];
73
+ const rest: EventVocabularyItem[] = [];
74
+ for (const def of data.index.allDefinitions()) {
75
+ if (def.kind !== kind || seen.has(def.name)) continue;
76
+ if (def.source === "mod" && !inFocus(def.file)) continue;
77
+ seen.add(def.name);
78
+ (def.source === "mod" ? mod : rest).push({
79
+ value: def.name,
80
+ doc: short(def.doc),
81
+ hint: def.source === "mod" ? "this mod" : def.source,
82
+ });
83
+ }
84
+ mod.sort((a, b) => a.value.localeCompare(b.value));
85
+ rest.sort((a, b) => a.value.localeCompare(b.value));
86
+ return [...mod, ...rest].slice(0, EVENT_VOCABULARY_MAX_VALUES);
87
+ }
88
+
59
89
  export function computeEventVocabulary(
60
90
  data: ServerData,
61
91
  schema: SchemaData,
@@ -73,22 +103,7 @@ export function computeEventVocabulary(
73
103
  const definitionsOf = (kind: string): EventVocabularyItem[] => {
74
104
  let cached = byKind.get(kind);
75
105
  if (cached) return cached;
76
- const seen = new Set<string>();
77
- const mod: EventVocabularyItem[] = [];
78
- const rest: EventVocabularyItem[] = [];
79
- for (const def of data.index.allDefinitions()) {
80
- if (def.kind !== kind || seen.has(def.name)) continue;
81
- if (def.source === "mod" && !inFocus(def.file)) continue;
82
- seen.add(def.name);
83
- (def.source === "mod" ? mod : rest).push({
84
- value: def.name,
85
- doc: short(def.doc),
86
- hint: def.source === "mod" ? "this mod" : def.source,
87
- });
88
- }
89
- mod.sort((a, b) => a.value.localeCompare(b.value));
90
- rest.sort((a, b) => a.value.localeCompare(b.value));
91
- cached = [...mod, ...rest].slice(0, EVENT_VOCABULARY_MAX_VALUES);
106
+ cached = definitionsOfKind(data, kind, inFocus);
92
107
  byKind.set(kind, cached);
93
108
  return cached;
94
109
  };
@@ -193,22 +208,7 @@ export function computeValueOptions(
193
208
  if (/^-?[\d.]+$/.test(name) || name.includes(":") || name.includes(".")) return null;
194
209
  const def = data.index.lookup(name).find((d) => !VALUE_KIND_SKIP.has(d.kind));
195
210
  if (!def) return null;
196
- const seen = new Set<string>();
197
- const mod: EventVocabularyItem[] = [];
198
- const rest: EventVocabularyItem[] = [];
199
- for (const d of data.index.allDefinitions()) {
200
- if (d.kind !== def.kind || seen.has(d.name)) continue;
201
- if (d.source === "mod" && !inFocus(d.file)) continue;
202
- seen.add(d.name);
203
- (d.source === "mod" ? mod : rest).push({
204
- value: d.name,
205
- doc: short(d.doc),
206
- hint: d.source === "mod" ? "this mod" : d.source,
207
- });
208
- }
209
- mod.sort((a, b) => a.value.localeCompare(b.value));
210
- rest.sort((a, b) => a.value.localeCompare(b.value));
211
- const items = [...mod, ...rest].slice(0, EVENT_VOCABULARY_MAX_VALUES);
211
+ const items = definitionsOfKind(data, def.kind, inFocus);
212
212
  // A single entry (the value itself) enumerates nothing worth a menu.
213
213
  return items.length > 1 ? { kind: def.kind, items } : null;
214
214
  }
@@ -186,7 +186,10 @@ export function buildExampleWikiIndex(src: ExampleWikiSources): ExampleWikiIndex
186
186
  }
187
187
  entries.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
188
188
 
189
- const sources = [`Triggers, effects, event targets and modifiers come from ${src.tokenSource}.`];
189
+ // tokenSource is a sentence tail that may already end in a period.
190
+ const sources = [
191
+ `Triggers, effects, event targets and modifiers come from ${src.tokenSource.replace(/\.$/, "")}.`,
192
+ ];
190
193
  sources.push(
191
194
  src.dataTypes.source === "data_types.log"
192
195
  ? "Datafunctions and data types come from your own DumpDataTypes output."
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as fs from "fs";
8
8
  import * as path from "path";
9
+ import { resolveConfigDir } from "@px-lsp/protocol/configDir";
9
10
  import { activeProfile } from "../games/active";
10
11
  import type { GameProfile } from "../games/profile";
11
12
  import type { AmbientScope, SchemaEntry, KeySpec, RefField, SchemaOverlay } from "./types";
@@ -74,7 +75,7 @@ export function loadSchema(modPath: string | string[] | null, log?: (msg: string
74
75
  // first, collisions are rare and per-path).
75
76
  const roots = modPath === null ? [] : Array.isArray(modPath) ? modPath : [modPath];
76
77
  for (const root of roots) {
77
- const overlayFile = path.join(root, profile.configDirName, "schema.json");
78
+ const overlayFile = path.join(resolveConfigDir(root, profile), "schema.json");
78
79
  try {
79
80
  if (!fs.existsSync(overlayFile)) continue;
80
81
  const overlay = JSON.parse(fs.readFileSync(overlayFile, "utf8")) as SchemaOverlay;