@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.
- package/README.md +1 -1
- package/data/ck3/skeletons.json +1 -0
- package/data/eu5/skeletons.json +1 -0
- package/data/vic3/skeletons.json +1 -0
- package/dist/browser-data/ck3/docs.json +1 -1
- package/dist/browser-data/ck3/tokens.json +1 -1
- package/dist/browser-data/vic3/tokens.json +1 -1
- package/dist/browser.js +50 -37
- package/dist/server.js +2000 -352
- package/dist/types/packages/server/src/features/blockSnippets.d.ts +20 -4
- package/dist/types/packages/server/src/features/completion.d.ts +11 -0
- package/dist/types/packages/server/src/features/definitionSkeletons.d.ts +24 -0
- package/dist/types/packages/server/src/games/ck3/schema.d.ts +1 -1
- package/dist/types/packages/server/src/games/eu5/index.d.ts +1 -1
- package/dist/types/packages/server/src/games/profile.d.ts +85 -1
- package/dist/types/packages/server/src/schema/skeletons.d.ts +102 -0
- package/dist/types/packages/server/src/schema/types.d.ts +30 -0
- package/package.json +3 -3
- package/src/coa/coa.ts +31 -0
- package/src/coa/coaDesigner.ts +131 -0
- package/src/coa/coaParse.ts +3 -0
- package/src/creators/definitionEdit.ts +198 -0
- package/src/creators/definitionForm.ts +479 -0
- package/src/creators/modifierFormats.ts +0 -0
- package/src/data/docsParser.ts +21 -4
- package/src/features/blockSnippets.ts +151 -20
- package/src/features/calendarDates.ts +8 -5
- package/src/features/completion.ts +38 -4
- package/src/features/definitionSkeletons.ts +109 -0
- package/src/features/inlayHints.ts +5 -2
- package/src/features/locText.ts +223 -0
- package/src/features/snippetList.ts +83 -0
- package/src/games/ck3/index.ts +16 -0
- package/src/games/ck3/meta.ts +81 -1
- package/src/games/ck3/schema.ts +83 -7
- package/src/games/ck3/structures.ts +37 -0
- package/src/games/eu5/index.ts +7 -1
- package/src/games/eu5/meta.ts +5 -1
- package/src/games/profile.ts +77 -1
- package/src/games/vic3/index.ts +4 -0
- package/src/games/vic3/meta.ts +5 -1
- package/src/gui/sourceModel.ts +51 -8
- package/src/gui/textResolve.ts +3 -3
- package/src/overview/dynastyTree.ts +493 -0
- package/src/overview/eventGraph.ts +21 -1
- package/src/overview/eventVocabulary.ts +33 -33
- package/src/overview/exampleWiki.ts +4 -1
- package/src/schema/loader.ts +2 -1
- package/src/schema/skeletons.ts +182 -0
- package/src/schema/types.ts +30 -0
- 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
|