@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,198 @@
1
+ /**
2
+ * paradox/definitionEdit: the script sibling of paradox/guiSourceEdit.
3
+ *
4
+ * A visual creator must be able to change one key of a definition without
5
+ * reformatting the file it lives in, and to add a whole definition without
6
+ * disturbing the ones already there. Both are the writer the GUI editor
7
+ * already has: `gui/sourceModel.ts`'s span model (read with the SCRIPT dialect,
8
+ * which has no child-declaration rule) and `gui/sourceEdit.ts`'s surgical
9
+ * replaces over the spans it recorded. Nothing is re-serialized, so a file's
10
+ * comments, CRLF, indentation and other definitions stay byte-identical.
11
+ *
12
+ * `upsertBlock` is `coa/coaParse.ts`'s `upsertFlagInFile` behaviour expressed
13
+ * as an edit instead of a whole new file text: replace the top-level block of
14
+ * that name, or append it after one blank separator line in the file's own
15
+ * newline style.
16
+ *
17
+ * The server never writes: it answers offsets into the text it was handed and
18
+ * the host applies them as ONE WorkspaceEdit (host-owns-text, EMBEDDING.md).
19
+ * A request it cannot honour comes back as a per-op REFUSAL with a reason,
20
+ * never as a throw.
21
+ *
22
+ * No `vscode` imports: unit-tested in plain Node.
23
+ */
24
+ import type {
25
+ DefinitionEditParams,
26
+ DefinitionEditResult,
27
+ DefinitionOp,
28
+ GuiTextEdit,
29
+ } from "@px-lsp/protocol/protocol";
30
+ import {
31
+ findEntry,
32
+ parseGuiSource,
33
+ SCRIPT_DIALECT,
34
+ type GuiEntry,
35
+ type GuiSourceFile,
36
+ } from "../gui/sourceModel";
37
+ import { insertProperties, removeProperty, setValue, type GuiEdit } from "../gui/sourceEdit";
38
+
39
+ /** The top-level `name = { ... }` entry, matched exactly: script names are case-sensitive. */
40
+ function topLevelBlock(file: GuiSourceFile, name: string): GuiEntry | null {
41
+ for (const entry of file.root.entries) {
42
+ if (entry.key === name && entry.valueKind === "block") return entry;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** Retype a block written with `\n` into the file's own line ending. */
48
+ function toFileNewline(text: string, file: GuiSourceFile): string {
49
+ return text.replace(/\r?\n/g, file.newline);
50
+ }
51
+
52
+ /**
53
+ * Replace the block of `name`, or append it. Appending puts exactly one blank
54
+ * line between the file's last content and the new block, and a file that is
55
+ * empty (or holds nothing but its BOM) gets the block on its own with no
56
+ * leading blank line to open it.
57
+ *
58
+ * `appended` is what earlier ops of the SAME batch already add at the end of
59
+ * the file: the separator is decided against the text as it will read, so five
60
+ * appended blocks are separated like one appended block is.
61
+ */
62
+ function upsertBlock(
63
+ file: GuiSourceFile,
64
+ name: string,
65
+ blockText: string,
66
+ appended: string
67
+ ): GuiEdit | string {
68
+ const trimmed = blockText.trim();
69
+ if (name.trim() === "" || trimmed === "") {
70
+ return "an upsert needs both a definition name and the block text to write.";
71
+ }
72
+ const body = toFileNewline(trimmed, file);
73
+ const existing = topLevelBlock(file, name);
74
+ if (existing) return { start: existing.span.start, end: existing.span.end, newText: body };
75
+
76
+ const text = file.text + appended;
77
+ const eol = file.newline;
78
+ const at = file.text.length;
79
+ if (text.trim() === "") return { start: at, end: at, newText: body + eol };
80
+ const closeLastLine = text.endsWith("\n") ? "" : eol;
81
+ return { start: at, end: at, newText: closeLastLine + eol + body + eol };
82
+ }
83
+
84
+ /**
85
+ * Set or remove keys on one definition. Rewrites the LAST entry for a key (the
86
+ * engine's own last-in-wins order) and adds the keys the block does not have in
87
+ * one shared insert.
88
+ */
89
+ function setProperties(
90
+ file: GuiSourceFile,
91
+ name: string,
92
+ properties: readonly { key: string; value: string | null }[]
93
+ ): GuiEdit[] | string {
94
+ const target = topLevelBlock(file, name);
95
+ if (!target) {
96
+ return `this file has no top-level \`${name} = { … }\` to change: write the whole block instead.`;
97
+ }
98
+ const edits: GuiEdit[] = [];
99
+ const missing: [string, string][] = [];
100
+ for (const { key, value } of properties ?? []) {
101
+ if (typeof key !== "string" || key.trim() === "") continue;
102
+ const existing = target.body ? findEntry(target.body, key) : null;
103
+ if (value === null) {
104
+ const edit = existing ? removeProperty(file, existing) : null;
105
+ if (edit) edits.push(edit);
106
+ continue;
107
+ }
108
+ if (existing) {
109
+ const edit = setValue(file, existing, value);
110
+ if (edit) edits.push(edit);
111
+ } else {
112
+ missing.push([key, value]);
113
+ }
114
+ }
115
+ const insert = missing.length > 0 ? insertProperties(file, target, missing) : null;
116
+ if (insert) edits.push(insert);
117
+ return edits;
118
+ }
119
+
120
+ /** Two edits over the same bytes cannot both be applied. */
121
+ function overlaps(a: readonly GuiTextEdit[], b: readonly GuiTextEdit[]): boolean {
122
+ return a.some((x) => b.some((y) => x.start < y.end && y.start < x.end));
123
+ }
124
+
125
+ /**
126
+ * Answers a batch against the one authoritative `text`. Every op is computed
127
+ * against the SAME source model, so a creator's save is one document change
128
+ * and one undo step; a refusal is that op's own answer and skips only it.
129
+ */
130
+ export function computeDefinitionEdits(params: DefinitionEditParams | null): DefinitionEditResult {
131
+ const text = params?.text ?? "";
132
+ const ops: DefinitionOp[] = Array.isArray(params?.ops) ? params.ops : [];
133
+ if (ops.length === 0) return { edits: [], ops: [] };
134
+
135
+ const file = parseGuiSource(text, SCRIPT_DIALECT);
136
+ if (file.errors.length > 0) {
137
+ const refused = `this file has ${file.errors.length} parse error(s), so no offset in it can be trusted: fix the syntax first.`;
138
+ return { edits: [], ops: ops.map(() => ({ refused })) };
139
+ }
140
+
141
+ const edits: GuiTextEdit[] = [];
142
+ const verdicts: { refused?: string }[] = [];
143
+ // Appends all land at the end of the text, where nothing separates one
144
+ // zero-width edit from the next: they are grown into ONE edit in request
145
+ // order, so the file reads in the order the client asked for.
146
+ const endOfFile = text.length;
147
+ let appended = "";
148
+ let appendEdit: GuiTextEdit | null = null;
149
+ for (const op of ops) {
150
+ const answer = runOp(file, op, appended);
151
+ if (typeof answer === "string") {
152
+ verdicts.push({ refused: answer });
153
+ continue;
154
+ }
155
+ if (answer.length === 0) {
156
+ // Nothing to do is not a refusal: the file already says what was asked.
157
+ verdicts.push({});
158
+ continue;
159
+ }
160
+ if (overlaps(edits, answer)) {
161
+ verdicts.push({
162
+ refused:
163
+ "another change in the same save already rewrites those bytes, so this one was left out: make it on its own.",
164
+ });
165
+ continue;
166
+ }
167
+ for (const edit of answer) {
168
+ if (edit.start === endOfFile && edit.end === endOfFile) {
169
+ appended += edit.newText;
170
+ if (appendEdit) appendEdit.newText += edit.newText;
171
+ else {
172
+ appendEdit = { ...edit };
173
+ edits.push(appendEdit);
174
+ }
175
+ continue;
176
+ }
177
+ edits.push(edit);
178
+ }
179
+ verdicts.push({});
180
+ }
181
+ return { edits, ops: verdicts };
182
+ }
183
+
184
+ function runOp(file: GuiSourceFile, op: DefinitionOp, appended: string): GuiEdit[] | string {
185
+ if (!op || typeof op.op !== "string") return "the server has no such edit.";
186
+ switch (op.op) {
187
+ case "setProperties": {
188
+ const answer = setProperties(file, op.name ?? "", op.properties ?? []);
189
+ return typeof answer === "string" ? answer : answer;
190
+ }
191
+ case "upsertBlock": {
192
+ const answer = upsertBlock(file, op.name ?? "", op.text ?? "", appended);
193
+ return typeof answer === "string" ? answer : [answer];
194
+ }
195
+ default:
196
+ return "the server has no such edit.";
197
+ }
198
+ }
@@ -0,0 +1,479 @@
1
+ /**
2
+ * paradox/definitionForm: everything a visual creator needs to draw a form for
3
+ * one definition kind, assembled from data the server already holds.
4
+ *
5
+ * The rule the creators are built on is the same one the event vocabulary
6
+ * follows: a no-code editor is only as honest as its sources, so nothing here
7
+ * is written for the creator.
8
+ *
9
+ * folder / locPatterns / iconFolder the schema table entry for the kind
10
+ * keys / blocks the profile's structure layer, which is
11
+ * the harvest of the game's own `_*.info`
12
+ * docs with the curated specs on top
13
+ * options the definition index, through the SAME
14
+ * resolver paradox/eventValueOptions uses
15
+ * conditions the profile's condition table, resolved
16
+ * against the script_docs trigger entries
17
+ * and the same definition index
18
+ * modifiers the script_docs modifier tokens hover
19
+ * and completion already read
20
+ * existing the index walk paradox/modOverview does
21
+ * current the file on disk, verbatim
22
+ *
23
+ * A game patch that adds a key, a trait or a modifier changes the form without
24
+ * a release, and a game whose schema has no such kind gets `null` rather than
25
+ * an invented shape.
26
+ *
27
+ * No `vscode` imports: unit-tested in plain Node.
28
+ */
29
+ import * as fs from "fs";
30
+ import {
31
+ DEFINITION_FORM_MAX_EXAMPLE,
32
+ DEFINITION_FORM_MAX_SAMPLED,
33
+ EVENT_VOCABULARY_MAX_VALUES,
34
+ type DefinitionForm,
35
+ type DefinitionFormKey,
36
+ type DefinitionFormParams,
37
+ type EventVocabularyItem,
38
+ type OverviewDef,
39
+ } from "@px-lsp/protocol/protocol";
40
+ import { decode, parseScript, type BlockNode, type ValueNode } from "../parser";
41
+ import type { SchemaData } from "../schema/loader";
42
+ import type { KeySpec } from "../schema/types";
43
+ import type { ServerData } from "../serverData";
44
+ import { definitionsOfKind, short } from "../overview/eventVocabulary";
45
+ import { activeProfile } from "../games/active";
46
+ import type { ConditionValueSource } from "../games/profile";
47
+
48
+ /**
49
+ * Same cap as the overview's per-kind definition list. It holds a whole game's
50
+ * worth of a creator's kind with room for a mod's own (301 vanilla traits is
51
+ * the largest of them, measured), and the mod's definitions are listed first,
52
+ * so a list that did hit the cap would only lose vanilla entries from the end
53
+ * of the alphabet.
54
+ */
55
+ const EXISTING_CAP = 500;
56
+
57
+ function formKeys(specs: Map<string, KeySpec> | undefined): DefinitionFormKey[] {
58
+ if (!specs) return [];
59
+ // Insertion order IS the answer's order: schema/loader.ts fills the map from
60
+ // StructureSpec.topLevel, which the profile already sorted (curated first,
61
+ // then harvested by vanilla usage count).
62
+ return [...specs.values()].map((spec) => ({
63
+ key: spec.key,
64
+ ...(short(spec.doc) ? { doc: short(spec.doc) } : {}),
65
+ ...(spec.values ? { values: spec.values } : {}),
66
+ ...(spec.freq !== undefined ? { freq: spec.freq } : {}),
67
+ ...(spec.refKinds?.length ? { refKinds: spec.refKinds } : {}),
68
+ }));
69
+ }
70
+
71
+ /**
72
+ * One script file's top-level `name = { ... }` blocks and its text, parsed once
73
+ * per request. Read from disk, not from the index: the index records where a
74
+ * definition is, a creator needs the bytes so an edit starts from what the file
75
+ * actually says, and both the group key and the sampled values are read out of
76
+ * the same parse.
77
+ */
78
+ /** One definition: its parsed body, and its `name = { ... }` source verbatim. */
79
+ interface ParsedDef {
80
+ block: BlockNode;
81
+ source: string;
82
+ /** Where `source` starts in the file, so a statement's range indexes into it. */
83
+ start: number;
84
+ }
85
+ type FileCache = Map<string, Map<string, ParsedDef>>;
86
+
87
+ function parsedFile(file: string, cache: FileCache): Map<string, ParsedDef> {
88
+ const seen = cache.get(file);
89
+ if (seen) return seen;
90
+ const defs = new Map<string, ParsedDef>();
91
+ cache.set(file, defs);
92
+ let text: string;
93
+ try {
94
+ text = decode(fs.readFileSync(file)).text;
95
+ } catch {
96
+ return defs;
97
+ }
98
+ for (const s of parseScript(text).root.statements) {
99
+ if (s.kind !== "assignment" || s.value?.kind !== "block") continue;
100
+ defs.set(s.key.text, {
101
+ block: s.value,
102
+ source: text.slice(s.key.range.start, s.value.range.end),
103
+ start: s.key.range.start,
104
+ });
105
+ }
106
+ return defs;
107
+ }
108
+
109
+ /** Every definition of `kind` the index holds, as (name, file) pairs. */
110
+ function definitionFiles(data: ServerData, kind: string): { name: string; file: string }[] {
111
+ const out: { name: string; file: string }[] = [];
112
+ for (const def of data.index.allDefinitions()) {
113
+ if (def.kind === kind) out.push({ name: def.name, file: def.file });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** The scalar `key = value` of a block, or null when it has none. */
119
+ function scalarOf(block: BlockNode, key: string): string | null {
120
+ for (const s of block.statements) {
121
+ if (s.kind === "assignment" && s.key.text === key && s.value?.kind === "scalar") return s.value.text;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * A value worth offering back: a bare name the game writes for this key. Numbers
128
+ * are weights and coordinates, quoted text is prose, `scope:`/`@` are script
129
+ * machinery - none of them is a value set a picker can show.
130
+ */
131
+ function offerable(text: string): boolean {
132
+ return text !== "" && !/^-?\d+(\.\d+)?$/.test(text) && !text.includes(":") && !text.startsWith("@");
133
+ }
134
+
135
+ /** The names a value writes: a scalar, or the entries of a one-level list. */
136
+ function namesIn(value: ValueNode, into: (name: string) => void): void {
137
+ if (value.kind === "scalar") {
138
+ if (!value.quoted && offerable(value.text)) into(value.text);
139
+ return;
140
+ }
141
+ if (value.kind !== "block") return;
142
+ for (const s of value.statements) {
143
+ // `coa_gfx = { a b }` (bare entries) and `ethnicities = { 100 = arab }`
144
+ // (weighted entries) both name things; a nested block is script, not a name.
145
+ const inner = s.kind === "value" ? s.value : s.value;
146
+ if (inner?.kind === "scalar" && !inner.quoted && offerable(inner.text)) into(inner.text);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Label each option with the family its definition belongs to, for a kind whose
152
+ * schema entry names the key that says so: one folder holds all five culture
153
+ * pillars and only `type = ethos` inside a block tells an ethos from a language,
154
+ * so a creator drawing five pickers has no other way to split the one list.
155
+ */
156
+ function groupOptions(
157
+ data: ServerData,
158
+ schema: SchemaData,
159
+ kind: string,
160
+ items: EventVocabularyItem[],
161
+ cache: FileCache
162
+ ): void {
163
+ const groupKey = schema.entries.find((e) => e.kind === kind)?.groupKey;
164
+ if (!groupKey) return;
165
+ const groups = new Map<string, string>();
166
+ for (const { name, file } of definitionFiles(data, kind)) {
167
+ const def = parsedFile(file, cache).get(name);
168
+ const group = def ? scalarOf(def.block, groupKey) : null;
169
+ if (group !== null) groups.set(name, group);
170
+ }
171
+ for (const item of items) {
172
+ const group = groups.get(item.value);
173
+ if (group !== undefined) item.group = group;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * What the game itself writes for the keys no definition index can answer: a
179
+ * culture's `clothing_gfx` names an art set and `ethnicities` names a portrait
180
+ * ethnicity, neither of which is an indexed definition, so the only honest
181
+ * source is the files. Measured here rather than stored, so a game patch or a
182
+ * dependency mod changes the offer without a release.
183
+ *
184
+ * A key whose values are different in every definition (a `desc` loc key) has
185
+ * no value set at all: past the cap it is dropped rather than offered as a list
186
+ * of everything.
187
+ */
188
+ function sampledValues(data: ServerData, kind: string, keys: DefinitionFormKey[], cache: FileCache): void {
189
+ // Bools and enums are in: their value SET is known already, but the value the
190
+ // game writes most often for the key is still the honest thing a form shows
191
+ // in an empty control, and a dropdown reading only "not set" tells a modder
192
+ // nothing. A key answered by the definition index stays out: its options are
193
+ // the index's, and its widget has no placeholder slot to fill.
194
+ const wanted = keys.filter((k) => !k.refKinds?.length);
195
+ if (wanted.length === 0) return;
196
+ const counts = new Map<string, Map<string, number>>();
197
+ // Read in the same pass, but counted separately: an example is ONE literal
198
+ // the game writes (a number, a loc key, a quoted line), and those are exactly
199
+ // the values `offerable` refuses as a value SET.
200
+ const literals = new Map<string, Map<string, number>>();
201
+ // A block key has no scalar literal at all, so its example is the BODY the
202
+ // game writes most often for it: what a script field shows as a placeholder.
203
+ const bodies = new Map<string, Map<string, number>>();
204
+ for (const key of wanted) {
205
+ counts.set(key.key, new Map());
206
+ literals.set(key.key, new Map());
207
+ bodies.set(key.key, new Map());
208
+ }
209
+ let read = 0;
210
+ for (const { name, file } of definitionFiles(data, kind)) {
211
+ if (read >= EVENT_VOCABULARY_MAX_VALUES) break;
212
+ const def = parsedFile(file, cache).get(name);
213
+ if (!def) continue;
214
+ read++;
215
+ for (const s of def.block.statements) {
216
+ if (s.kind !== "assignment" || !s.value) continue;
217
+ const bucket = counts.get(s.key.text);
218
+ if (!bucket) continue;
219
+ namesIn(s.value, (v) => bucket.set(v, (bucket.get(v) ?? 0) + 1));
220
+ if (s.value.kind === "scalar" && s.value.text !== "") {
221
+ const seen = literals.get(s.key.text)!;
222
+ seen.set(s.value.text, (seen.get(s.value.text) ?? 0) + 1);
223
+ } else if (s.value.kind === "block") {
224
+ const body = oneLineBody(
225
+ def.source.slice(s.value.range.start - def.start, s.value.range.end - def.start)
226
+ );
227
+ if (body !== "") {
228
+ const seen = bodies.get(s.key.text)!;
229
+ seen.set(body, (seen.get(body) ?? 0) + 1);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ const mostUsed = (bucket: Map<string, number>): [string, number][] =>
235
+ [...bucket.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
236
+ for (const key of wanted) {
237
+ const bucket = counts.get(key.key)!;
238
+ // A key whose value set the schema or the doc already states (`bool`,
239
+ // `enum:`) gets no measured one: two lists of the same thing, one of them
240
+ // only as complete as the files happen to be.
241
+ const stated = key.values === "bool" || key.values?.startsWith("enum:") === true;
242
+ if (!stated && bucket.size > 0 && bucket.size <= DEFINITION_FORM_MAX_SAMPLED) {
243
+ key.sampled = mostUsed(bucket).map(([value]) => value);
244
+ }
245
+ // Kept even when the value set was dropped for being past the cap: a key
246
+ // whose value differs in every definition is exactly the one a form has to
247
+ // show an example for.
248
+ // A scalar literal first: it is what most keys are. A key the game only
249
+ // ever writes as a block falls back to its most written body, so a script
250
+ // field is never the one field with no example on it.
251
+ const example = mostUsed(literals.get(key.key)!)[0] ?? mostUsed(bodies.get(key.key)!)[0];
252
+ if (example) key.example = example[0];
253
+ }
254
+ }
255
+
256
+ /**
257
+ * A `{ … }` body as a placeholder can be: one line, inner runs of whitespace
258
+ * collapsed, cut with an ellipsis rather than dropped when it is long. The
259
+ * braces stay, because they are what the modder has to type.
260
+ */
261
+ function oneLineBody(text: string): string {
262
+ const flat = text
263
+ .replace(/#[^\n]*/g, " ")
264
+ .replace(/\s+/g, " ")
265
+ .trim();
266
+ return flat.length > DEFINITION_FORM_MAX_EXAMPLE
267
+ ? `${flat.slice(0, DEFINITION_FORM_MAX_EXAMPLE - 1).trimEnd()}…`
268
+ : flat;
269
+ }
270
+
271
+ /**
272
+ * The values a `Valid …:` metadata line of a script_docs entry enumerates.
273
+ *
274
+ * The line is stored with its own label in front of it, because the docs
275
+ * parser keeps the label it matched ("Traits: Valid Features: a, b, and c",
276
+ * measured on the trigger dump of the profile that names one). A value never
277
+ * holds a colon, so the list is what follows the LAST one; the last entry
278
+ * carries the prose "and". A token whose traits hold several metadata lines is
279
+ * read line by line, and the first line that enumerates anything wins.
280
+ */
281
+ function docListValues(traits: string): string[] {
282
+ for (const line of traits.split("\n")) {
283
+ const names = line
284
+ .slice(line.lastIndexOf(":") + 1)
285
+ .split(",")
286
+ .map((part) =>
287
+ part
288
+ .trim()
289
+ .replace(/^and\s+/i, "")
290
+ .replace(/\.$/, "")
291
+ )
292
+ .filter((name) => /^[A-Za-z][A-Za-z0-9_]*$/.test(name));
293
+ if (names.length > 0) return [...new Set(names)];
294
+ }
295
+ return [];
296
+ }
297
+
298
+ /** The inner block keys of every definition of a kind (a game rule's settings). */
299
+ function innerKeysOf(data: ServerData, kind: string, except: readonly string[], cache: FileCache): string[] {
300
+ const skip = new Set(except);
301
+ const names: string[] = [];
302
+ for (const { name, file } of definitionFiles(data, kind)) {
303
+ const def = parsedFile(file, cache).get(name);
304
+ if (!def) continue;
305
+ for (const s of def.block.statements) {
306
+ if (s.kind !== "assignment" || s.value?.kind !== "block") continue;
307
+ if (skip.has(s.key.text) || names.includes(s.key.text)) continue;
308
+ names.push(s.key.text);
309
+ }
310
+ }
311
+ return names;
312
+ }
313
+
314
+ /**
315
+ * The value list one trigger of the profile's condition table resolves to, or
316
+ * an empty list when this workspace has no source for it (no script_docs dump,
317
+ * no game folder): the answer is then the trigger's ABSENCE from `conditions`,
318
+ * which a creator draws as a free input rather than an empty picker.
319
+ */
320
+ function conditionItems(
321
+ data: ServerData,
322
+ source: ConditionValueSource,
323
+ trigger: string,
324
+ inFocus: (file: string) => boolean,
325
+ cache: FileCache
326
+ ): EventVocabularyItem[] {
327
+ if (source.from === "kind") return definitionsOfKind(data, source.kind, inFocus);
328
+ const values =
329
+ source.from === "docList"
330
+ ? docListValues(data.tokenMap.get(trigger)?.find((token) => token.traits)?.traits ?? "")
331
+ : innerKeysOf(data, source.kind, source.except ?? [], cache);
332
+ return values.slice(0, EVENT_VOCABULARY_MAX_VALUES).map((value) => ({ value }));
333
+ }
334
+
335
+ /**
336
+ * The name the PLAYER reads for a definition. The loc key is the schema's own
337
+ * pattern for the kind with `$` replaced by the name; a kind whose entry names
338
+ * none gets the two shapes the games write for a bare definition, and a name
339
+ * nothing resolves for keeps no label at all rather than a made-up one.
340
+ */
341
+ function labeller(data: ServerData, schema: SchemaData): (kind: string, name: string) => string | undefined {
342
+ const patterns = new Map<string, string[]>();
343
+ const patternsFor = (kind: string): string[] => {
344
+ let list = patterns.get(kind);
345
+ if (!list) {
346
+ const entry = schema.entries.find((e) => e.kind === kind);
347
+ const own = entry?.locPatterns ?? entry?.requiredLoc ?? [];
348
+ list = own.length > 0 ? [own[0]] : ["$_name", "$"];
349
+ patterns.set(kind, list);
350
+ }
351
+ return list;
352
+ };
353
+ const locValue = (key: string): string | undefined =>
354
+ data.index.lookup(key).find((d) => d.kind === "loc_key" && d.value !== undefined)?.value;
355
+ return (kind, name) => {
356
+ for (const pattern of patternsFor(kind)) {
357
+ const value = locValue(pattern.replace("$", name));
358
+ if (value === undefined || value === "") continue;
359
+ // A value that is only another key (`tradition_hird_name:0
360
+ // "$innovation_hird$"`, 10 of ~200 vanilla traditions) reads as that
361
+ // key's text; one hop, the way the game resolves it.
362
+ const alias = /^\$([\w.-]+)\$$/.exec(value);
363
+ const resolved = alias ? locValue(alias[1]) : undefined;
364
+ return resolved !== undefined && resolved !== "" ? resolved : value;
365
+ }
366
+ return undefined;
367
+ };
368
+ }
369
+
370
+ export function computeDefinitionForm(
371
+ data: ServerData,
372
+ schema: SchemaData,
373
+ params: DefinitionFormParams,
374
+ inFocus: (file: string) => boolean = () => true
375
+ ): DefinitionForm | null {
376
+ const kind = params?.kind?.trim() ?? "";
377
+ const entry = schema.entries.find((e) => e.kind === kind);
378
+ if (kind === "" || !entry) return null;
379
+
380
+ const blocks = schema.structures.keysByKindBlock.get(kind);
381
+ const keys = formKeys(blocks?.get(""));
382
+ const subBlocks: Record<string, DefinitionFormKey[]> = {};
383
+ for (const [name, specs] of blocks ?? []) {
384
+ if (name !== "") subBlocks[name] = formKeys(specs);
385
+ }
386
+
387
+ const files: FileCache = new Map();
388
+ const labelOf = labeller(data, schema);
389
+
390
+ // One list per ref kind any key names, so several keys pointing at the same
391
+ // kind share it instead of shipping the list twice.
392
+ const options: Record<string, EventVocabularyItem[]> = {};
393
+ for (const key of keys) {
394
+ for (const refKind of key.refKinds ?? []) {
395
+ if (options[refKind]) continue;
396
+ options[refKind] = definitionsOfKind(data, refKind, inFocus);
397
+ groupOptions(data, schema, refKind, options[refKind], files);
398
+ // A picker reads better with the player's word for the definition than
399
+ // with its key, and only the loc index knows it.
400
+ for (const item of options[refKind]) {
401
+ const label = labelOf(refKind, item.value);
402
+ if (label !== undefined) item.label = label;
403
+ }
404
+ }
405
+ }
406
+
407
+ // Keys no index can answer (a culture's clothing_gfx, its ethnicities) get
408
+ // what the indexed definitions of this kind actually write for them.
409
+ sampledValues(data, kind, keys, files);
410
+
411
+ // The trigger value lists a no-code condition builder needs. The profile
412
+ // names the triggers and their sources; a trigger nothing resolves for stays
413
+ // out, so a client can tell "no list" from "an empty list".
414
+ const conditions: Record<string, EventVocabularyItem[]> = {};
415
+ for (const [trigger, source] of Object.entries(activeProfile().conditionValues ?? {})) {
416
+ const items = conditionItems(data, source, trigger, inFocus, files);
417
+ if (items.length > 0) conditions[trigger] = items;
418
+ }
419
+
420
+ // What the Open menu offers: everything the index has of this kind, the
421
+ // mod's own first. A creator opens a vanilla definition to duplicate or
422
+ // override it, which is most of what a modder does with one, so a list of
423
+ // only the mod's own definitions could never answer "start from the game's".
424
+ const byName = new Map<string, OverviewDef>();
425
+ for (const def of data.index.allDefinitions()) {
426
+ if (def.kind !== kind) continue;
427
+ if (def.source === "mod" && !inFocus(def.file)) continue;
428
+ const seen = byName.get(def.name);
429
+ // Last-in-wins: the mod's copy is the one a modder means by the name.
430
+ if (seen && !(def.source === "mod" && seen.source !== "mod")) continue;
431
+ const label = labelOf(kind, def.name);
432
+ byName.set(def.name, {
433
+ name: def.name,
434
+ file: def.file,
435
+ line: def.line,
436
+ source: def.source,
437
+ ...(label !== undefined ? { label } : {}),
438
+ });
439
+ }
440
+ const existing = [...byName.values()]
441
+ .sort((a, b) => Number(b.source === "mod") - Number(a.source === "mod") || a.name.localeCompare(b.name))
442
+ .slice(0, EXISTING_CAP);
443
+
444
+ // Modifier rows: the same token list hover documents, ranked by the reference
445
+ // index's usage counts so the modifiers a real corpus writes come first.
446
+ const modifiers = data.tokens
447
+ .filter((t) => t.kind === "modifier")
448
+ .sort((a, b) => data.refIndex.usageCount(b.name) - data.refIndex.usageCount(a.name))
449
+ .slice(0, EVENT_VOCABULARY_MAX_VALUES)
450
+ .map((t) => ({ name: t.name, ...(short(t.doc) ? { doc: short(t.doc) } : {}) }));
451
+
452
+ const form: DefinitionForm = {
453
+ kind,
454
+ folder: entry.path,
455
+ // The whole set the game generates, which is a superset of the
456
+ // conservative requiredLoc a diagnostic is allowed to demand.
457
+ locPatterns: entry.locPatterns ?? entry.requiredLoc ?? [],
458
+ ...(entry.iconFolder ? { iconFolder: entry.iconFolder } : {}),
459
+ keys,
460
+ ...(Object.keys(subBlocks).length > 0 ? { blocks: subBlocks } : {}),
461
+ options,
462
+ ...(Object.keys(conditions).length > 0 ? { conditions } : {}),
463
+ modifiers,
464
+ existing,
465
+ };
466
+
467
+ const wanted = params.name?.trim();
468
+ if (wanted) {
469
+ // Mod first: editing "the trait called X" means the one this mod ships,
470
+ // and the vanilla copy only when the mod has none (last-in-wins order).
471
+ const defs = data.index.lookup(wanted).filter((d) => d.kind === kind);
472
+ const def = defs.find((d) => d.source === "mod" && inFocus(d.file)) ?? defs[0];
473
+ const parsed = def ? parsedFile(def.file, files).get(wanted) : undefined;
474
+ if (def && parsed) {
475
+ form.current = { file: def.file, line: def.line, source: def.source, text: parsed.source };
476
+ }
477
+ }
478
+ return form;
479
+ }
Binary file