@px-lsp/server 0.1.0
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/LICENSE +674 -0
- package/README.md +334 -0
- package/THIRD-PARTY-NOTICES.md +83 -0
- package/data/ck3/dataTypes.json +2195 -0
- package/data/ck3/data_types/data_types_common.txt +2040 -0
- package/data/ck3/data_types/data_types_gui.txt +5264 -0
- package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
- package/data/ck3/data_types/data_types_script.txt +4251 -0
- package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
- package/data/ck3/freqs.json +1 -0
- package/data/ck3/guiSchema.json +6344 -0
- package/data/ck3/script_docs/effects.log +16059 -0
- package/data/ck3/script_docs/event_targets.log +2098 -0
- package/data/ck3/script_docs/modifiers.log +2228 -0
- package/data/ck3/script_docs/on_actions.log +5275 -0
- package/data/ck3/script_docs/triggers.log +11991 -0
- package/data/ck3/structures.json +9743 -0
- package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
- package/data/ck3/wikidocs/Data_types.md +2568 -0
- package/data/ck3/wikidocs/Effects_list.md +1176 -0
- package/data/ck3/wikidocs/Scopes_list.md +341 -0
- package/data/ck3/wikidocs/Triggers_list.md +1097 -0
- package/data/eu5/data_types/data_types_common.txt +2087 -0
- package/data/eu5/data_types/data_types_gui.txt +6732 -0
- package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
- package/data/eu5/data_types/data_types_script.txt +5688 -0
- package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
- package/data/vic3/data_types/data_types_common.txt +2021 -0
- package/data/vic3/data_types/data_types_gui.txt +5592 -0
- package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
- package/data/vic3/data_types/data_types_script.txt +2817 -0
- package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
- package/data/vic3/freqs.json +1 -0
- package/data/vic3/guiSchema.json +5578 -0
- package/data/vic3/script_docs/effects.log +38135 -0
- package/data/vic3/script_docs/event_targets.log +2028 -0
- package/data/vic3/script_docs/modifiers.log +18954 -0
- package/data/vic3/script_docs/on_actions.log +1561 -0
- package/data/vic3/script_docs/triggers.log +15738 -0
- package/data/vic3/structures.json +10189 -0
- package/dist/server.js +63668 -0
- package/media/px-lsp.svg +12 -0
- package/package.json +50 -0
- package/src/clientMode.ts +60 -0
- package/src/coa/coa.ts +184 -0
- package/src/coa/coaParse.ts +267 -0
- package/src/context.ts +78 -0
- package/src/contextKeywords.ts +224 -0
- package/src/data/dataBindingMacros.ts +82 -0
- package/src/data/dataFnDocs.ts +152 -0
- package/src/data/dataFnUsage.ts +431 -0
- package/src/data/dataTypes.ts +279 -0
- package/src/data/defines.ts +123 -0
- package/src/data/docsParser.ts +453 -0
- package/src/data/keywordDocs.ts +98 -0
- package/src/data/modifierTemplates.ts +143 -0
- package/src/data/textFormatting.ts +165 -0
- package/src/data/wikiDocs.ts +187 -0
- package/src/dds/decoder.ts +1007 -0
- package/src/dds/encode.ts +235 -0
- package/src/dds/index.ts +58 -0
- package/src/dds/png.ts +96 -0
- package/src/dds/tga.ts +62 -0
- package/src/documents.ts +35 -0
- package/src/features/assetPaths.ts +169 -0
- package/src/features/codeActions.ts +148 -0
- package/src/features/colors.ts +244 -0
- package/src/features/completion.ts +961 -0
- package/src/features/datafunction.ts +729 -0
- package/src/features/definition.ts +84 -0
- package/src/features/diagnostics.ts +244 -0
- package/src/features/folding.ts +106 -0
- package/src/features/formatting.ts +60 -0
- package/src/features/guiLanguage.ts +366 -0
- package/src/features/guiNavigation.ts +140 -0
- package/src/features/guiTree.ts +97 -0
- package/src/features/hover.ts +817 -0
- package/src/features/hoverRender.ts +222 -0
- package/src/features/inlayHints.ts +147 -0
- package/src/features/locFormatting.ts +127 -0
- package/src/features/references.ts +70 -0
- package/src/features/rename.ts +135 -0
- package/src/features/scopeAt.ts +65 -0
- package/src/features/semanticTokens.ts +188 -0
- package/src/features/signatureHelp.ts +72 -0
- package/src/features/symbols.ts +241 -0
- package/src/features/textureHover.ts +143 -0
- package/src/features/workspaceSymbols.ts +69 -0
- package/src/games/active.ts +19 -0
- package/src/games/ck3/ambientScopes.ts +273 -0
- package/src/games/ck3/index.ts +38 -0
- package/src/games/ck3/meta.ts +28 -0
- package/src/games/ck3/modifierPlaceholders.ts +61 -0
- package/src/games/ck3/saveSchema.ts +134 -0
- package/src/games/ck3/scaffolds.ts +197 -0
- package/src/games/ck3/schema.ts +422 -0
- package/src/games/ck3/structures.ts +887 -0
- package/src/games/eu5/index.ts +75 -0
- package/src/games/eu5/meta.ts +44 -0
- package/src/games/eu5/scaffolds.ts +49 -0
- package/src/games/eu5/schema.generated.ts +1043 -0
- package/src/games/jomini/variables.ts +134 -0
- package/src/games/profile.ts +205 -0
- package/src/games/registry.ts +27 -0
- package/src/games/vic3/index.ts +52 -0
- package/src/games/vic3/meta.ts +55 -0
- package/src/games/vic3/saveSchema.ts +77 -0
- package/src/games/vic3/scaffolds.ts +135 -0
- package/src/games/vic3/schema.ts +650 -0
- package/src/games/vic3/structures.ts +33 -0
- package/src/gui/anchorSpec.ts +66 -0
- package/src/gui/declMarkers.ts +30 -0
- package/src/gui/fillGeometry.ts +101 -0
- package/src/gui/guiDefs.ts +386 -0
- package/src/gui/guiDependencies.ts +352 -0
- package/src/gui/guiLinks.ts +64 -0
- package/src/gui/layoutEngine.ts +1998 -0
- package/src/gui/layoutService.ts +221 -0
- package/src/gui/measuredMetrics.ts +21 -0
- package/src/gui/previewService.ts +89 -0
- package/src/gui/saveSchema.ts +220 -0
- package/src/gui/saveValues.ts +399 -0
- package/src/gui/saveZip.ts +60 -0
- package/src/gui/sourceEdit.ts +535 -0
- package/src/gui/sourceEditService.ts +439 -0
- package/src/gui/sourceModel.ts +603 -0
- package/src/gui/textResolve.ts +145 -0
- package/src/gui/textureInfo.ts +106 -0
- package/src/gui/vocabulary.ts +149 -0
- package/src/gui/widgetEdit.ts +52 -0
- package/src/gui/widgetInfo.ts +245 -0
- package/src/index/docComments.ts +103 -0
- package/src/index/extract.ts +252 -0
- package/src/index/indexer.ts +369 -0
- package/src/index/intern.ts +101 -0
- package/src/index/lazyRefs.ts +145 -0
- package/src/index/modOrigin.ts +69 -0
- package/src/index/references.ts +534 -0
- package/src/overview/dependencies.ts +240 -0
- package/src/overview/eventBanner.ts +95 -0
- package/src/overview/eventDetail.ts +482 -0
- package/src/overview/eventGraph.ts +617 -0
- package/src/overview/eventVocabulary.ts +214 -0
- package/src/overview/locCoverage.ts +138 -0
- package/src/overview/modOverview.ts +29 -0
- package/src/overview/overrides.ts +89 -0
- package/src/parseCache.ts +81 -0
- package/src/parser/cst.ts +257 -0
- package/src/parser/encoding.ts +106 -0
- package/src/parser/index.ts +7 -0
- package/src/parser/lexer.ts +245 -0
- package/src/parser/locParser.ts +276 -0
- package/src/parser/parser.ts +360 -0
- package/src/schema/freqs.ts +70 -0
- package/src/schema/loader.ts +113 -0
- package/src/schema/types.ts +142 -0
- package/src/scopes/inference.ts +478 -0
- package/src/scopes/model.ts +148 -0
- package/src/scopes/varTypes.ts +290 -0
- package/src/server.ts +1894 -0
- package/src/serverData.ts +98 -0
- package/src/structure.ts +56 -0
- package/src/wordAt.ts +49 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paradox/eventGraph: event ↔ trigger_event ↔ on_action chains as a graph, scoped
|
|
3
|
+
* to a root definition or namespace to stay readable. Edges come from the
|
|
4
|
+
* reference index (mod usage sites); node metadata from the definition index.
|
|
5
|
+
*
|
|
6
|
+
* Two things the naive reading gets wrong, both fixed here:
|
|
7
|
+
*
|
|
8
|
+
* - A namespace (or the whole mod) is its DEFINITIONS, not the endpoints of the
|
|
9
|
+
* edges between them. Deriving the node set from edges hides every event that
|
|
10
|
+
* nothing calls yet, which is exactly the event a modder just wrote.
|
|
11
|
+
* - Most real mods route their event chains through scripted effects, so the
|
|
12
|
+
* `trigger_event` sits in a scripted_effect body and the event that calls that
|
|
13
|
+
* effect looks like it fires nothing. Those calls are followed transitively
|
|
14
|
+
* (bounded, visited-guarded) and reported as A → B labeled "via X", the same
|
|
15
|
+
* walk shape `gui/guiDependencies.ts` uses over the same reference index.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import type {
|
|
19
|
+
EventGraph,
|
|
20
|
+
EventGraphEdge,
|
|
21
|
+
EventGraphNode,
|
|
22
|
+
EventGraphParams,
|
|
23
|
+
EventGraphStep,
|
|
24
|
+
EventGraphSuggestions,
|
|
25
|
+
} from "@px-lsp/protocol/protocol";
|
|
26
|
+
import type { Reference } from "@px-lsp/protocol/types";
|
|
27
|
+
import type { ServerData } from "../serverData";
|
|
28
|
+
import {
|
|
29
|
+
decode,
|
|
30
|
+
LineIndex,
|
|
31
|
+
nodeAtOffset,
|
|
32
|
+
parseScript,
|
|
33
|
+
type BlockNode,
|
|
34
|
+
type ParseResult,
|
|
35
|
+
type ValueNode,
|
|
36
|
+
} from "../parser";
|
|
37
|
+
|
|
38
|
+
const DEFAULT_MAX_NODES = 400;
|
|
39
|
+
const GRAPH_KINDS = new Set(["event", "on_action", "decision"]);
|
|
40
|
+
/** A query box lists a page at a time; a whole big mod is enough to type against. */
|
|
41
|
+
const MAX_SUGGESTIONS = 2000;
|
|
42
|
+
/**
|
|
43
|
+
* How many scripted-effect hops a chain follows. Three is what the readout
|
|
44
|
+
* ("via effect_a → effect_b") stays legible at, and it bounds the expansion a
|
|
45
|
+
* single request can do (guiDependencies.ts uses the same budget).
|
|
46
|
+
*/
|
|
47
|
+
const MAX_EFFECT_HOPS = 3;
|
|
48
|
+
|
|
49
|
+
interface Edge {
|
|
50
|
+
from: string;
|
|
51
|
+
to: string;
|
|
52
|
+
via: string;
|
|
53
|
+
/** Reference site, for labeling the edge with its origin block. The char
|
|
54
|
+
* pins the NAME itself, so the path lookup reaches the innermost statement
|
|
55
|
+
* (`id = X` inside trigger_event, `100 = X` inside random_events). */
|
|
56
|
+
file: string;
|
|
57
|
+
line: number;
|
|
58
|
+
char: number;
|
|
59
|
+
label?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Localized title of a definition, trying the common key conventions. */
|
|
63
|
+
function titleOf(data: ServerData, id: string): string | undefined {
|
|
64
|
+
for (const key of [`${id}.t`, `${id.replace(/\./g, "_")}_t`, `${id}.title`, id]) {
|
|
65
|
+
const loc = data.index.lookup(key).find((d) => d.kind === "loc_key");
|
|
66
|
+
if (loc?.value) return loc.value;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** "30d", "7–14d", "2mo", "1y": the delay a `trigger_event` block spells. */
|
|
72
|
+
function delayOf(block: BlockNode): string | undefined {
|
|
73
|
+
const suffix: Record<string, string> = { days: "d", months: "mo", years: "y" };
|
|
74
|
+
for (const stmt of block.statements) {
|
|
75
|
+
if (stmt.kind !== "assignment") continue;
|
|
76
|
+
const unit = suffix[stmt.key.text.toLowerCase()];
|
|
77
|
+
if (!unit) continue;
|
|
78
|
+
if (stmt.value?.kind === "scalar") return `${stmt.value.text}${unit}`;
|
|
79
|
+
const range = blockOf(stmt.value);
|
|
80
|
+
if (range) {
|
|
81
|
+
const ends: string[] = [];
|
|
82
|
+
for (const s of range.statements) {
|
|
83
|
+
if (s.kind === "value" && s.value.kind === "scalar") ends.push(s.value.text);
|
|
84
|
+
}
|
|
85
|
+
if (ends.length === 2) return `${ends[0]}–${ends[1]}${unit}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Label an edge with WHERE the reference lives inside the source event — the
|
|
93
|
+
* text of its option, or the section name (immediate/after/on_actions…) — and
|
|
94
|
+
* WHEN: the step's own line (`fromLine`, the row-anchor join key), the
|
|
95
|
+
* normalized `phase`, the `trigger_event` delay, and the `random_events`
|
|
96
|
+
* weight. Parses each source file once per request; fail-soft to a bare edge.
|
|
97
|
+
*/
|
|
98
|
+
function labelEdges(data: ServerData, edges: EventGraphEdge[], sites: Map<EventGraphEdge, Edge>): void {
|
|
99
|
+
const parses = new Map<string, { result: ParseResult; li: LineIndex } | null>();
|
|
100
|
+
const parseOf = (file: string) => {
|
|
101
|
+
const key = file.toLowerCase();
|
|
102
|
+
if (!parses.has(key)) {
|
|
103
|
+
try {
|
|
104
|
+
const text = decode(fs.readFileSync(file)).text;
|
|
105
|
+
parses.set(key, { result: parseScript(text), li: new LineIndex(text) });
|
|
106
|
+
} catch {
|
|
107
|
+
parses.set(key, null);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return parses.get(key)!;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
for (const edge of edges) {
|
|
114
|
+
const site = sites.get(edge);
|
|
115
|
+
if (!site) continue;
|
|
116
|
+
const parsed = parseOf(site.file);
|
|
117
|
+
if (!parsed) continue;
|
|
118
|
+
const offset = parsed.li.offsetAt({ line: site.line, character: site.char });
|
|
119
|
+
const hit = nodeAtOffset(parsed.result.root, offset + 1);
|
|
120
|
+
if (!hit) continue;
|
|
121
|
+
let label: string | undefined;
|
|
122
|
+
let inRandom = false;
|
|
123
|
+
for (const stmt of hit.path) {
|
|
124
|
+
if (stmt.kind !== "assignment") continue;
|
|
125
|
+
const key = stmt.key.text.toLowerCase();
|
|
126
|
+
const isStep = key === "option" || key === "immediate" || key === "after";
|
|
127
|
+
// The OUTERMOST step key is the card row the edge anchors at; the path
|
|
128
|
+
// runs outer to inner and an event's steps do not nest in each other.
|
|
129
|
+
if (isStep && edge.phase === undefined) {
|
|
130
|
+
edge.phase = key;
|
|
131
|
+
edge.fromLine = parsed.li.positionAt(stmt.key.range.start).line;
|
|
132
|
+
}
|
|
133
|
+
if (key === "option") {
|
|
134
|
+
// Use the option's localized text when available.
|
|
135
|
+
const block = blockOf(stmt.value);
|
|
136
|
+
const nameStmt = block?.statements.find(
|
|
137
|
+
(s) => s.kind === "assignment" && s.key.text.toLowerCase() === "name" && s.value?.kind === "scalar"
|
|
138
|
+
);
|
|
139
|
+
const nameKey =
|
|
140
|
+
nameStmt?.kind === "assignment" && nameStmt.value?.kind === "scalar" ? nameStmt.value.text : null;
|
|
141
|
+
const text = nameKey ? data.index.lookup(nameKey).find((d) => d.kind === "loc_key")?.value : null;
|
|
142
|
+
label = text ? `option: ${text.length > 28 ? text.slice(0, 27) + "…" : text}` : "option";
|
|
143
|
+
} else if (
|
|
144
|
+
[
|
|
145
|
+
"immediate",
|
|
146
|
+
"after",
|
|
147
|
+
"on_actions",
|
|
148
|
+
"trigger",
|
|
149
|
+
"effect",
|
|
150
|
+
"events",
|
|
151
|
+
"random_events",
|
|
152
|
+
"first_valid",
|
|
153
|
+
].includes(key)
|
|
154
|
+
) {
|
|
155
|
+
label = key;
|
|
156
|
+
if (edge.phase === undefined && !isStep) edge.phase = key;
|
|
157
|
+
if (key === "random_events") inRandom = true;
|
|
158
|
+
} else if (inRandom && /^\d+$/.test(key)) {
|
|
159
|
+
// `random_events = { 100 = ns.2 }`: the weight is the entry's key.
|
|
160
|
+
edge.weight = Number(key);
|
|
161
|
+
}
|
|
162
|
+
if (key === "trigger_event") {
|
|
163
|
+
const block = blockOf(stmt.value);
|
|
164
|
+
if (block) edge.delay = delayOf(block);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// A "via effect" chain label wins over the section name: the chain is the
|
|
168
|
+
// part the reader cannot see from the row anchor alone.
|
|
169
|
+
if (label && !edge.label) edge.label = label;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function computeEventGraph(
|
|
174
|
+
data: ServerData,
|
|
175
|
+
params: EventGraphParams,
|
|
176
|
+
inFocus: (file: string) => boolean = () => true
|
|
177
|
+
): EventGraph {
|
|
178
|
+
const maxNodes = params.maxNodes ?? DEFAULT_MAX_NODES;
|
|
179
|
+
|
|
180
|
+
// Definitions per file, sorted by line, to resolve a reference's containing
|
|
181
|
+
// definition. The focus filter scopes the graph to one workspace mod: edges
|
|
182
|
+
// originate only from focus files (targets may resolve anywhere).
|
|
183
|
+
// scripted_effect is in here so a reference written INSIDE an effect body is
|
|
184
|
+
// attributed to that effect instead of being dropped.
|
|
185
|
+
const defsByFile = new Map<string, Array<{ name: string; kind: string; line: number }>>();
|
|
186
|
+
// Same pass feeds the query-box catalog and the namespace/all node sets: both
|
|
187
|
+
// must list the mod's whole vocabulary, edges or no edges.
|
|
188
|
+
const vocabulary = new Set<string>();
|
|
189
|
+
for (const def of data.index.allDefinitions()) {
|
|
190
|
+
if (def.source !== "mod" || !inFocus(def.file)) continue;
|
|
191
|
+
const graphKind = GRAPH_KINDS.has(def.kind);
|
|
192
|
+
if (!graphKind && def.kind !== "scripted_effect") continue;
|
|
193
|
+
const key = def.file.toLowerCase();
|
|
194
|
+
let list = defsByFile.get(key);
|
|
195
|
+
if (!list) defsByFile.set(key, (list = []));
|
|
196
|
+
list.push({ name: def.name, kind: def.kind, line: def.line });
|
|
197
|
+
if (graphKind) vocabulary.add(def.name);
|
|
198
|
+
}
|
|
199
|
+
for (const list of defsByFile.values()) list.sort((a, b) => a.line - b.line);
|
|
200
|
+
|
|
201
|
+
const containerOf = (ref: Reference): { name: string; kind: string } | null => {
|
|
202
|
+
const list = defsByFile.get(ref.file.toLowerCase());
|
|
203
|
+
if (!list) return null;
|
|
204
|
+
let best: { name: string; kind: string } | null = null;
|
|
205
|
+
for (const d of list) {
|
|
206
|
+
if (d.line <= ref.line) best = d;
|
|
207
|
+
else break;
|
|
208
|
+
}
|
|
209
|
+
return best;
|
|
210
|
+
};
|
|
211
|
+
const isScriptedEffect = (name: string): boolean =>
|
|
212
|
+
data.index.lookup(name).some((d) => d.kind === "scripted_effect");
|
|
213
|
+
|
|
214
|
+
// One pass over the reference index feeds three tables: the direct edges, what
|
|
215
|
+
// each scripted effect fires, and who calls which scripted effect.
|
|
216
|
+
const edges: Edge[] = [];
|
|
217
|
+
const direct = new Set<string>();
|
|
218
|
+
/** scripted effect -> the events / on_actions its own body fires. */
|
|
219
|
+
const effectFires = new Map<string, Edge[]>();
|
|
220
|
+
/** definition -> the scripted effects it calls, each with its FIRST call
|
|
221
|
+
* site: the expansion edge anchors there (the caller's own option or
|
|
222
|
+
* immediate block), not somewhere inside the effect's file. */
|
|
223
|
+
const effectCalls = new Map<string, Map<string, { file: string; line: number; char: number }>>();
|
|
224
|
+
const addCall = (from: string, effect: string, site: { file: string; line: number; char: number }) => {
|
|
225
|
+
let calls = effectCalls.get(from);
|
|
226
|
+
if (!calls) effectCalls.set(from, (calls = new Map()));
|
|
227
|
+
if (!calls.has(effect)) calls.set(effect, site);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (const ref of data.refIndex.all()) {
|
|
231
|
+
const fires = ref.kinds.some((k) => k === "event" || k === "on_action");
|
|
232
|
+
const call = ref.call === true && ref.kinds.includes("scripted_effect");
|
|
233
|
+
if (!fires && !call) continue;
|
|
234
|
+
const from = containerOf(ref);
|
|
235
|
+
if (!from) continue;
|
|
236
|
+
if (call) {
|
|
237
|
+
// An effect calling ITSELF would loop the expansion; an event firing
|
|
238
|
+
// itself (a repeating chain) is a real edge and stays.
|
|
239
|
+
if (from.name !== ref.name && isScriptedEffect(ref.name))
|
|
240
|
+
addCall(from.name, ref.name, { file: ref.file, line: ref.line, char: ref.startChar });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const edge: Edge = {
|
|
244
|
+
from: from.name,
|
|
245
|
+
to: ref.name,
|
|
246
|
+
via: ref.kinds.includes("on_action") ? "on_action" : "event",
|
|
247
|
+
file: ref.file,
|
|
248
|
+
line: ref.line,
|
|
249
|
+
char: ref.startChar,
|
|
250
|
+
};
|
|
251
|
+
if (from.kind === "scripted_effect") {
|
|
252
|
+
let list = effectFires.get(from.name);
|
|
253
|
+
if (!list) effectFires.set(from.name, (list = []));
|
|
254
|
+
list.push(edge);
|
|
255
|
+
} else {
|
|
256
|
+
edges.push(edge);
|
|
257
|
+
direct.add(`${edge.from}→${edge.to}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Expand each graph node's scripted-effect calls into the events they reach.
|
|
262
|
+
// The chain is carried so the edge can say which effect it went through; the
|
|
263
|
+
// visited set is per START NODE, so two events sharing an effect both get it.
|
|
264
|
+
for (const [from, firstHop] of effectCalls) {
|
|
265
|
+
if (!vocabulary.has(from)) continue; // an effect calling an effect: expanded from its callers
|
|
266
|
+
const visited = new Set<string>();
|
|
267
|
+
// The whole chain anchors where the CALLER calls its first effect: that is
|
|
268
|
+
// the option (or immediate/after block) the reader's event fires it from.
|
|
269
|
+
let frontier: Array<{
|
|
270
|
+
effect: string;
|
|
271
|
+
chain: string[];
|
|
272
|
+
site: { file: string; line: number; char: number };
|
|
273
|
+
}> = [];
|
|
274
|
+
for (const [effect, site] of firstHop) {
|
|
275
|
+
visited.add(effect);
|
|
276
|
+
frontier.push({ effect, chain: [effect], site });
|
|
277
|
+
}
|
|
278
|
+
for (let hop = 0; hop < MAX_EFFECT_HOPS && frontier.length > 0; hop++) {
|
|
279
|
+
const next: typeof frontier = [];
|
|
280
|
+
for (const step of frontier) {
|
|
281
|
+
for (const fired of effectFires.get(step.effect) ?? []) {
|
|
282
|
+
if (fired.to === from || direct.has(`${from}→${fired.to}`)) continue;
|
|
283
|
+
direct.add(`${from}→${fired.to}`);
|
|
284
|
+
edges.push({
|
|
285
|
+
from,
|
|
286
|
+
to: fired.to,
|
|
287
|
+
via: fired.via,
|
|
288
|
+
file: step.site.file,
|
|
289
|
+
line: step.site.line,
|
|
290
|
+
char: step.site.char,
|
|
291
|
+
label: `via ${step.chain.join(" → ")}`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
for (const [deeper] of effectCalls.get(step.effect) ?? []) {
|
|
295
|
+
if (visited.has(deeper)) continue;
|
|
296
|
+
visited.add(deeper);
|
|
297
|
+
next.push({ effect: deeper, chain: [...step.chain, deeper], site: step.site });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
frontier = next;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Adjacency for BFS in both directions.
|
|
305
|
+
const adj = new Map<string, Edge[]>();
|
|
306
|
+
const addAdj = (id: string, e: Edge) => {
|
|
307
|
+
let list = adj.get(id);
|
|
308
|
+
if (!list) adj.set(id, (list = []));
|
|
309
|
+
list.push(e);
|
|
310
|
+
};
|
|
311
|
+
for (const e of edges) {
|
|
312
|
+
addAdj(e.from, e);
|
|
313
|
+
addAdj(e.to, e);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Select nodes: BFS from root, or namespace filter, or all (capped).
|
|
317
|
+
const selected = new Set<string>();
|
|
318
|
+
let truncated = false;
|
|
319
|
+
if (params.root) {
|
|
320
|
+
const queue = [params.root];
|
|
321
|
+
selected.add(params.root);
|
|
322
|
+
while (queue.length > 0 && selected.size < maxNodes) {
|
|
323
|
+
const id = queue.shift()!;
|
|
324
|
+
for (const e of adj.get(id) ?? []) {
|
|
325
|
+
for (const next of [e.from, e.to]) {
|
|
326
|
+
if (selected.has(next)) continue;
|
|
327
|
+
if (selected.size >= maxNodes) {
|
|
328
|
+
truncated = true;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
selected.add(next);
|
|
332
|
+
queue.push(next);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
const ns = params.namespace;
|
|
338
|
+
const inScope = (id: string): boolean => (ns ? id.startsWith(ns + ".") : true);
|
|
339
|
+
// The mod's own definitions come first and unconditionally: an event nothing
|
|
340
|
+
// references yet is still part of its namespace.
|
|
341
|
+
const ids = new Set<string>();
|
|
342
|
+
for (const id of vocabulary) {
|
|
343
|
+
if (inScope(id)) ids.add(id);
|
|
344
|
+
}
|
|
345
|
+
// Then whatever those definitions are wired to, in or out.
|
|
346
|
+
for (const e of edges) {
|
|
347
|
+
if (!inScope(e.from) && !inScope(e.to)) continue;
|
|
348
|
+
ids.add(e.from);
|
|
349
|
+
ids.add(e.to);
|
|
350
|
+
}
|
|
351
|
+
for (const id of [...ids].sort()) {
|
|
352
|
+
if (selected.size >= maxNodes) {
|
|
353
|
+
truncated = true;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
selected.add(id);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const graphEdges: EventGraphEdge[] = [];
|
|
361
|
+
const sites = new Map<EventGraphEdge, Edge>();
|
|
362
|
+
const edgeSeen = new Set<string>();
|
|
363
|
+
for (const e of edges) {
|
|
364
|
+
if (!selected.has(e.from) || !selected.has(e.to)) continue;
|
|
365
|
+
// The site keeps edges from DIFFERENT origins apart: two options that
|
|
366
|
+
// both fire X are two sequences, and each anchors at its own card row.
|
|
367
|
+
// char included so two same-target refs on one line stay two edges.
|
|
368
|
+
const key = `${e.from}→${e.to}:${e.via}:${e.label ?? ""}:${e.line}:${e.char}`;
|
|
369
|
+
if (edgeSeen.has(key)) continue;
|
|
370
|
+
edgeSeen.add(key);
|
|
371
|
+
const out: EventGraphEdge = { from: e.from, to: e.to, via: e.via };
|
|
372
|
+
graphEdges.push(out);
|
|
373
|
+
// A "via X" edge keeps its chain label, but its SITE is the caller's own
|
|
374
|
+
// call line, so the pass below still anchors it at the option (or
|
|
375
|
+
// immediate/after block) that fires the chain.
|
|
376
|
+
if (e.label) out.label = e.label;
|
|
377
|
+
sites.set(out, e);
|
|
378
|
+
}
|
|
379
|
+
labelEdges(data, graphEdges, sites);
|
|
380
|
+
|
|
381
|
+
// What each node fires, for the card's third line. Free: the edges are here.
|
|
382
|
+
const firesCount = new Map<string, number>();
|
|
383
|
+
for (const e of graphEdges) firesCount.set(e.from, (firesCount.get(e.from) ?? 0) + 1);
|
|
384
|
+
|
|
385
|
+
const nodes: EventGraphNode[] = [];
|
|
386
|
+
const factsOf = fileFacts();
|
|
387
|
+
for (const id of selected) {
|
|
388
|
+
const defs = data.index.lookup(id);
|
|
389
|
+
const def = defs[0];
|
|
390
|
+
const node: EventGraphNode = {
|
|
391
|
+
id,
|
|
392
|
+
kind: def?.kind ?? "unknown",
|
|
393
|
+
source: def?.source ?? "vanilla",
|
|
394
|
+
file: def?.file,
|
|
395
|
+
line: def?.line,
|
|
396
|
+
title: titleOf(data, id),
|
|
397
|
+
};
|
|
398
|
+
// Only this mod's own files are read: a card summarises what the author can
|
|
399
|
+
// edit, and parsing every vanilla event file a graph touches is not cheap.
|
|
400
|
+
const facts = def?.source === "mod" && def.file ? factsOf(def.file).get(id) : undefined;
|
|
401
|
+
if (facts) {
|
|
402
|
+
if (def?.kind === "event") {
|
|
403
|
+
node.options = facts.options;
|
|
404
|
+
if (facts.steps.length > 0) {
|
|
405
|
+
node.steps = facts.steps.map((s) => {
|
|
406
|
+
const step: EventGraphStep = { phase: s.phase, line: s.line };
|
|
407
|
+
if (s.index !== undefined) step.index = s.index;
|
|
408
|
+
const text = s.nameKey
|
|
409
|
+
? data.index.lookup(s.nameKey).find((d) => d.kind === "loc_key")?.value
|
|
410
|
+
: undefined;
|
|
411
|
+
if (text) step.text = text.length > 40 ? text.slice(0, 39) + "…" : text;
|
|
412
|
+
return step;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (facts.trigger) node.triggerSummary = facts.trigger;
|
|
417
|
+
// override_background wins over the theme: it is the picture the game
|
|
418
|
+
// actually shows for this event, and the banner resolver takes either.
|
|
419
|
+
if (params.themes && (facts.background ?? facts.theme)) node.theme = facts.background ?? facts.theme;
|
|
420
|
+
}
|
|
421
|
+
const fires = firesCount.get(id) ?? 0;
|
|
422
|
+
if (fires > 0) node.fires = fires;
|
|
423
|
+
nodes.push(node);
|
|
424
|
+
}
|
|
425
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
426
|
+
const graph: EventGraph = { nodes, edges: graphEdges, truncated, suggestions: suggestionsOf(vocabulary) };
|
|
427
|
+
if (nodes.length === 0) {
|
|
428
|
+
const reason = emptyReason(data, params, inFocus);
|
|
429
|
+
if (reason) graph.emptyReason = reason;
|
|
430
|
+
}
|
|
431
|
+
return graph;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Why an empty graph is empty, when the index can say: the queried namespace
|
|
436
|
+
* or root EXISTS, just outside what this graph shows (another workspace mod
|
|
437
|
+
* while a focus filter is on, a dependency mod, or vanilla). "Check the
|
|
438
|
+
* spelling" for content that is sitting right there sends the user hunting a
|
|
439
|
+
* typo that does not exist.
|
|
440
|
+
*/
|
|
441
|
+
function emptyReason(
|
|
442
|
+
data: ServerData,
|
|
443
|
+
params: EventGraphParams,
|
|
444
|
+
inFocus: (file: string) => boolean
|
|
445
|
+
): string | null {
|
|
446
|
+
const matches = (name: string): boolean =>
|
|
447
|
+
params.root ? name === params.root : params.namespace ? name.startsWith(params.namespace + ".") : false;
|
|
448
|
+
if (!params.root && !params.namespace) return null;
|
|
449
|
+
const what = params.root ?? `namespace ${params.namespace}`;
|
|
450
|
+
let hit: { source: string; file: string } | null = null;
|
|
451
|
+
for (const def of data.index.allDefinitions()) {
|
|
452
|
+
if (!GRAPH_KINDS.has(def.kind) || !matches(def.name)) continue;
|
|
453
|
+
// A mod definition outside the focus is the sharpest answer: stop there.
|
|
454
|
+
if (def.source === "mod" && !inFocus(def.file)) {
|
|
455
|
+
hit = def;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
if (def.source !== "mod" && !hit) hit = def;
|
|
459
|
+
}
|
|
460
|
+
if (!hit) return null;
|
|
461
|
+
if (hit.source === "mod") {
|
|
462
|
+
const root = data.modRootOf?.(hit.file);
|
|
463
|
+
const mod = root
|
|
464
|
+
? root
|
|
465
|
+
.replace(/[\\/]+$/, "")
|
|
466
|
+
.split(/[\\/]/)
|
|
467
|
+
.pop()
|
|
468
|
+
: null;
|
|
469
|
+
return `${what} exists in another workspace mod${mod ? ` (${mod})` : ""}, outside the current focus mod. Switch the focus mod (Paradox: Pick Focus Mod), or open the graph from one of that mod's files.`;
|
|
470
|
+
}
|
|
471
|
+
if (hit.source === "parent") {
|
|
472
|
+
return `${what} is in a dependency (parent) mod. The graph shows only the workspace mods being edited.`;
|
|
473
|
+
}
|
|
474
|
+
return `${what} is vanilla content. The graph shows only the workspace mods being edited.`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** One card row before loc resolution: the name KEY travels, not the text. */
|
|
478
|
+
interface DefStep {
|
|
479
|
+
phase: "immediate" | "option" | "after";
|
|
480
|
+
index?: number;
|
|
481
|
+
nameKey?: string;
|
|
482
|
+
line: number;
|
|
483
|
+
}
|
|
484
|
+
/** What a card says about one definition, all of it read from its own body. */
|
|
485
|
+
interface DefFacts {
|
|
486
|
+
/** `option = { … }` blocks. */
|
|
487
|
+
options: number;
|
|
488
|
+
/** The first keys of the `trigger` block, or "" when there is no trigger. */
|
|
489
|
+
trigger: string;
|
|
490
|
+
/** `theme = X`, for the banner layer. */
|
|
491
|
+
theme?: string;
|
|
492
|
+
/** `override_background = { reference = X }`: wins over the theme's picture. */
|
|
493
|
+
background?: string;
|
|
494
|
+
/** Card rows in execution order (immediate, options, after), capped. */
|
|
495
|
+
steps: DefStep[];
|
|
496
|
+
}
|
|
497
|
+
/** Trigger keys named on a card before it says "…". Two fit the card's width. */
|
|
498
|
+
const TRIGGER_KEYS_SHOWN = 2;
|
|
499
|
+
/** Rows a card shows; `options` carries the true count past the cap. */
|
|
500
|
+
const MAX_STEPS = 8;
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Every top-level definition of one file, summarised. Each file is parsed once
|
|
504
|
+
* per request; a file that cannot be read simply has no facts, which the caller
|
|
505
|
+
* renders as a card without a second and third line rather than a wrong one.
|
|
506
|
+
*/
|
|
507
|
+
function fileFacts(): (file: string) => Map<string, DefFacts> {
|
|
508
|
+
const byFile = new Map<string, Map<string, DefFacts>>();
|
|
509
|
+
return (file) => {
|
|
510
|
+
const key = file.toLowerCase();
|
|
511
|
+
let facts = byFile.get(key);
|
|
512
|
+
if (facts) return facts;
|
|
513
|
+
facts = new Map<string, DefFacts>();
|
|
514
|
+
byFile.set(key, facts);
|
|
515
|
+
try {
|
|
516
|
+
const text = decode(fs.readFileSync(file)).text;
|
|
517
|
+
const root = parseScript(text).root;
|
|
518
|
+
const li = new LineIndex(text);
|
|
519
|
+
for (const stmt of root.statements) {
|
|
520
|
+
if (stmt.kind !== "assignment") continue;
|
|
521
|
+
const block = blockOf(stmt.value);
|
|
522
|
+
if (!block) continue;
|
|
523
|
+
const entry: DefFacts = { options: 0, trigger: "", steps: [] };
|
|
524
|
+
let immediate: DefStep | null = null;
|
|
525
|
+
let after: DefStep | null = null;
|
|
526
|
+
const options: DefStep[] = [];
|
|
527
|
+
for (const child of block.statements) {
|
|
528
|
+
if (child.kind !== "assignment") continue;
|
|
529
|
+
const childKey = child.key.text.toLowerCase();
|
|
530
|
+
const lineOf = (): number => li.positionAt(child.key.range.start).line;
|
|
531
|
+
if (childKey === "option") {
|
|
532
|
+
const optionBlock = blockOf(child.value);
|
|
533
|
+
const nameStmt = optionBlock?.statements.find(
|
|
534
|
+
(s) =>
|
|
535
|
+
s.kind === "assignment" && s.key.text.toLowerCase() === "name" && s.value?.kind === "scalar"
|
|
536
|
+
);
|
|
537
|
+
const nameKey =
|
|
538
|
+
nameStmt?.kind === "assignment" && nameStmt.value?.kind === "scalar"
|
|
539
|
+
? nameStmt.value.text
|
|
540
|
+
: undefined;
|
|
541
|
+
options.push({ phase: "option", index: entry.options, nameKey, line: lineOf() });
|
|
542
|
+
entry.options++;
|
|
543
|
+
} else if (childKey === "immediate" && !immediate)
|
|
544
|
+
immediate = { phase: "immediate", line: lineOf() };
|
|
545
|
+
else if (childKey === "after" && !after) after = { phase: "after", line: lineOf() };
|
|
546
|
+
else if (childKey === "theme" && child.value?.kind === "scalar") entry.theme = child.value.text;
|
|
547
|
+
else if (childKey === "override_background") {
|
|
548
|
+
const ref = backgroundReference(child.value);
|
|
549
|
+
if (ref) entry.background = ref;
|
|
550
|
+
} else if (childKey === "trigger" && entry.trigger === "") entry.trigger = triggerKeys(child.value);
|
|
551
|
+
}
|
|
552
|
+
// Execution order, not file order: immediate runs on appear, then the
|
|
553
|
+
// player picks an option, then after runs.
|
|
554
|
+
entry.steps = [...(immediate ? [immediate] : []), ...options, ...(after ? [after] : [])].slice(
|
|
555
|
+
0,
|
|
556
|
+
MAX_STEPS
|
|
557
|
+
);
|
|
558
|
+
facts.set(stmt.key.text, entry);
|
|
559
|
+
}
|
|
560
|
+
} catch {
|
|
561
|
+
/* unreadable: memoized as empty so it is attempted once */
|
|
562
|
+
}
|
|
563
|
+
return facts;
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function blockOf(value: ValueNode | null | undefined): BlockNode | null {
|
|
568
|
+
if (value?.kind === "block") return value;
|
|
569
|
+
if (value?.kind === "tagged-block") return value.block;
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The reference of an `override_background` value: `{ reference = X }` in the
|
|
575
|
+
* current syntax, a bare scalar in the pre-1.5 one. Several blocks would be
|
|
576
|
+
* trigger-gated variants; the last reference is the file's own fallback.
|
|
577
|
+
*/
|
|
578
|
+
function backgroundReference(value: ValueNode | null | undefined): string | null {
|
|
579
|
+
if (value?.kind === "scalar") return value.text;
|
|
580
|
+
const block = blockOf(value);
|
|
581
|
+
if (!block) return null;
|
|
582
|
+
let ref: string | null = null;
|
|
583
|
+
for (const stmt of block.statements) {
|
|
584
|
+
if (stmt.kind !== "assignment") continue;
|
|
585
|
+
if (stmt.key.text.toLowerCase() === "reference" && stmt.value?.kind === "scalar") ref = stmt.value.text;
|
|
586
|
+
}
|
|
587
|
+
return ref;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** "is_adult, has_trait…": what a `trigger` block asks, short enough for a card. */
|
|
591
|
+
function triggerKeys(value: ValueNode | null | undefined): string {
|
|
592
|
+
const block = blockOf(value);
|
|
593
|
+
if (!block) return "";
|
|
594
|
+
const keys: string[] = [];
|
|
595
|
+
let more = false;
|
|
596
|
+
for (const stmt of block.statements) {
|
|
597
|
+
if (stmt.kind !== "assignment") continue;
|
|
598
|
+
if (keys.length < TRIGGER_KEYS_SHOWN) keys.push(stmt.key.text);
|
|
599
|
+
else {
|
|
600
|
+
more = true;
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (keys.length === 0) return "";
|
|
605
|
+
return keys.join(", ") + (more ? "…" : "");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The mod's own graph ids and the namespaces they imply, both sorted. */
|
|
609
|
+
function suggestionsOf(vocabulary: Set<string>): EventGraphSuggestions {
|
|
610
|
+
const ids = [...vocabulary].sort();
|
|
611
|
+
const namespaces = new Set<string>();
|
|
612
|
+
for (const id of ids) {
|
|
613
|
+
const dot = id.indexOf(".");
|
|
614
|
+
if (dot > 0) namespaces.add(id.slice(0, dot));
|
|
615
|
+
}
|
|
616
|
+
return { ids: ids.slice(0, MAX_SUGGESTIONS), namespaces: [...namespaces].sort() };
|
|
617
|
+
}
|