@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,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paradox/guiLayout request backend: lazily builds the cross-file template/type
|
|
3
|
+
* store from the vanilla + mod gui trees (FIOS: path-sorted, first definition
|
|
4
|
+
* wins; a mod file replaces the vanilla file at the same relative path), then
|
|
5
|
+
* runs the measured layout engine over the requested document.
|
|
6
|
+
*
|
|
7
|
+
* The store build costs ~200ms over the full vanilla tree and is cached per
|
|
8
|
+
* (gamePath, modPath) pair; mod .gui edits re-collect only the mod side.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import type { GuiLayoutNode, GuiLayoutResult, GuiVisibilityOptions } from "@px-lsp/protocol/protocol";
|
|
13
|
+
import { collectGuiDefs, emptyGuiDefs, mergeGuiDefs, type GuiDefs } from "./guiDefs";
|
|
14
|
+
import {
|
|
15
|
+
calibratedMeasurer,
|
|
16
|
+
computeGuiLayout,
|
|
17
|
+
measurerFromMetrics,
|
|
18
|
+
type LayoutEnv,
|
|
19
|
+
type LayoutNode,
|
|
20
|
+
type LayoutTiming,
|
|
21
|
+
type VisibilityCheck,
|
|
22
|
+
} from "./layoutEngine";
|
|
23
|
+
import { collectScriptedGuiCalls, emptyGuiScriptLinks, type GuiScriptLinks } from "./guiLinks";
|
|
24
|
+
import { activeProfile } from "../games/active";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The active game's measured layout environment, when its profile carries
|
|
28
|
+
* probe results (GameProfile.guiTextMetrics / guiLayoutQuirks); undefined
|
|
29
|
+
* falls back to the engine's calibrated defaults.
|
|
30
|
+
*/
|
|
31
|
+
export function profileMeasurer(): LayoutEnv | undefined {
|
|
32
|
+
const { guiTextMetrics, guiLayoutQuirks } = activeProfile();
|
|
33
|
+
if (!guiTextMetrics && !guiLayoutQuirks) return undefined;
|
|
34
|
+
const base = guiTextMetrics ? measurerFromMetrics(guiTextMetrics) : calibratedMeasurer;
|
|
35
|
+
return { ...base, ...guiLayoutQuirks };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Matches the game's UI reference resolution at 100% scaling. */
|
|
39
|
+
export const VIEWPORT = { w: 1920, h: 1080 };
|
|
40
|
+
|
|
41
|
+
let cache: { key: string; defs: GuiDefs; files: number; links: GuiScriptLinks } | null = null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Per-file parse results, keyed by absolute path and kept across store
|
|
45
|
+
* rebuilds. A store rebuild used to re-read and re-parse every .gui of the
|
|
46
|
+
* game (about a thousand files) whenever ONE mod .gui was saved, which is
|
|
47
|
+
* exactly what autosave does after every editor gesture; now a rebuild
|
|
48
|
+
* re-parses only the files whose mtime moved and re-merges the rest.
|
|
49
|
+
*/
|
|
50
|
+
const fileCache = new Map<string, { mtimeMs: number; defs: GuiDefs; text: string }>();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Observer of a cold store build, so the server can report the phase without
|
|
54
|
+
* this module knowing about the connection. Absent = nobody is watching.
|
|
55
|
+
*/
|
|
56
|
+
let onBuild: ((state: "start" | "done") => void) | null = null;
|
|
57
|
+
|
|
58
|
+
export function observeGuiStoreBuild(fn: ((state: "start" | "done") => void) | null): void {
|
|
59
|
+
onBuild = fn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function listGuiFiles(root: string): Map<string, string> {
|
|
63
|
+
// relative path (lowercased, forward slashes) -> absolute path
|
|
64
|
+
const out = new Map<string, string>();
|
|
65
|
+
const walk = (dir: string): void => {
|
|
66
|
+
let entries: fs.Dirent[];
|
|
67
|
+
try {
|
|
68
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const e of entries) {
|
|
73
|
+
const p = path.join(dir, e.name);
|
|
74
|
+
if (e.isDirectory()) walk(p);
|
|
75
|
+
else if (e.name.toLowerCase().endsWith(".gui")) {
|
|
76
|
+
out.set(path.relative(root, p).toLowerCase().replace(/\\/g, "/"), p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(root);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildStore(
|
|
85
|
+
gamePath: string | null,
|
|
86
|
+
modPath: string | null,
|
|
87
|
+
parentPaths: string[],
|
|
88
|
+
engineRoots: string[]
|
|
89
|
+
): { defs: GuiDefs; files: number; links: GuiScriptLinks } {
|
|
90
|
+
// The stage prefix is part of the key: switching game changes which folder of
|
|
91
|
+
// the same roots the store was built from.
|
|
92
|
+
const stage = activeProfile().stageRoots?.[0] ?? "";
|
|
93
|
+
const key = `${stage}|${engineRoots.join(";")}|${gamePath ?? ""}|${parentPaths.join(";")}|${modPath ?? ""}`;
|
|
94
|
+
if (cache) {
|
|
95
|
+
if (cache.key === key) return cache;
|
|
96
|
+
cache = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Effective file set: later roots win the same relative path (whole-file
|
|
100
|
+
// replacement) — engine (jomini), vanilla, parent mods in load order, then the
|
|
101
|
+
// mod — and FIOS applies across the sorted result.
|
|
102
|
+
const effective = new Map<string, string>();
|
|
103
|
+
// Content roots of a game with load-stage folders carry the stage prefix
|
|
104
|
+
// (`in_game/gui/`); the engine layer next to the install never does, since it
|
|
105
|
+
// is shared by every stage.
|
|
106
|
+
const stagePrefix = stage ? [stage] : [];
|
|
107
|
+
for (const root of engineRoots) {
|
|
108
|
+
for (const [rel, abs] of listGuiFiles(path.join(root, "gui"))) effective.set(rel, abs);
|
|
109
|
+
}
|
|
110
|
+
for (const root of [gamePath, ...parentPaths, modPath]) {
|
|
111
|
+
if (!root) continue;
|
|
112
|
+
for (const [rel, abs] of listGuiFiles(path.join(root, ...stagePrefix, "gui"))) effective.set(rel, abs);
|
|
113
|
+
}
|
|
114
|
+
onBuild?.("start");
|
|
115
|
+
const defs = emptyGuiDefs();
|
|
116
|
+
const links = emptyGuiScriptLinks();
|
|
117
|
+
let files = 0;
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
for (const rel of [...effective.keys()].sort()) {
|
|
120
|
+
const abs = effective.get(rel)!;
|
|
121
|
+
try {
|
|
122
|
+
const mtimeMs = fs.statSync(abs).mtimeMs;
|
|
123
|
+
let entry = fileCache.get(abs);
|
|
124
|
+
if (!entry || entry.mtimeMs !== mtimeMs) {
|
|
125
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
126
|
+
entry = { mtimeMs, defs: collectGuiDefs(text, undefined, abs), text };
|
|
127
|
+
fileCache.set(abs, entry);
|
|
128
|
+
}
|
|
129
|
+
seen.add(abs);
|
|
130
|
+
mergeGuiDefs(defs, entry.defs);
|
|
131
|
+
// Piggybacked on the one pass that already reads every .gui file: the
|
|
132
|
+
// scripted_gui call scan is a substring test on all but the few files
|
|
133
|
+
// that hold one, so the link index is effectively free here and a
|
|
134
|
+
// second walk of the tree would not be.
|
|
135
|
+
collectScriptedGuiCalls(entry.text, abs, links);
|
|
136
|
+
files++;
|
|
137
|
+
} catch {
|
|
138
|
+
/* unreadable file: skip */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// A file that left the effective set (deleted, or shadowed by another root) drops out.
|
|
142
|
+
for (const abs of fileCache.keys()) if (!seen.has(abs)) fileCache.delete(abs);
|
|
143
|
+
cache = { key, defs, files, links };
|
|
144
|
+
onBuild?.("done");
|
|
145
|
+
return cache;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The cross-file template/type store (cached), for navigation and hover. */
|
|
149
|
+
export function getGuiDefs(
|
|
150
|
+
gamePath: string | null,
|
|
151
|
+
modPath: string | null,
|
|
152
|
+
parentPaths: string[] = [],
|
|
153
|
+
engineRoots: string[] = []
|
|
154
|
+
): GuiDefs {
|
|
155
|
+
return buildStore(gamePath, modPath, parentPaths, engineRoots).defs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The cross-file `GetScriptedGui(...)` call index (cached with the store). */
|
|
159
|
+
export function getGuiScriptLinks(
|
|
160
|
+
gamePath: string | null,
|
|
161
|
+
modPath: string | null,
|
|
162
|
+
parentPaths: string[] = [],
|
|
163
|
+
engineRoots: string[] = []
|
|
164
|
+
): GuiScriptLinks {
|
|
165
|
+
return buildStore(gamePath, modPath, parentPaths, engineRoots).links;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Drop the cached store (mod .gui saved, settings changed). */
|
|
169
|
+
export function invalidateGuiDefsCache(): void {
|
|
170
|
+
cache = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function computeGuiLayoutResult(
|
|
174
|
+
text: string,
|
|
175
|
+
gamePath: string | null,
|
|
176
|
+
modPath: string | null,
|
|
177
|
+
parentPaths: string[] = [],
|
|
178
|
+
engineRoots: string[] = [],
|
|
179
|
+
visibility?: GuiVisibilityOptions,
|
|
180
|
+
/** Loc/datafunction resolution for textboxes; absent = raw `text =` values. */
|
|
181
|
+
resolveText?: LayoutEnv["resolveText"]
|
|
182
|
+
): GuiLayoutResult {
|
|
183
|
+
const t0 = performance.now();
|
|
184
|
+
const { defs, files } = buildStore(gamePath, modPath, parentPaths, engineRoots);
|
|
185
|
+
const defsMs = performance.now() - t0;
|
|
186
|
+
const checks = new Map<string, VisibilityCheck>();
|
|
187
|
+
const timing: LayoutTiming = { parseMs: 0, layoutMs: 0 };
|
|
188
|
+
const nodes = computeGuiLayout(text, {
|
|
189
|
+
defs,
|
|
190
|
+
viewport: VIEWPORT,
|
|
191
|
+
visibility,
|
|
192
|
+
checks,
|
|
193
|
+
timing,
|
|
194
|
+
measurer: resolveText ? { ...(profileMeasurer() ?? calibratedMeasurer), resolveText } : profileMeasurer(),
|
|
195
|
+
});
|
|
196
|
+
const textures = new Set<string>();
|
|
197
|
+
let nodeCount = 0;
|
|
198
|
+
const visit = (n: LayoutNode): void => {
|
|
199
|
+
nodeCount++;
|
|
200
|
+
if (n.bg?.texture) textures.add(n.bg.texture);
|
|
201
|
+
if (n.fill?.texture) textures.add(n.fill.texture);
|
|
202
|
+
if (n.bg?.mask) textures.add(n.bg.mask);
|
|
203
|
+
if (n.fill?.mask) textures.add(n.fill.mask);
|
|
204
|
+
for (const c of n.children) visit(c);
|
|
205
|
+
};
|
|
206
|
+
for (const n of nodes) visit(n);
|
|
207
|
+
return {
|
|
208
|
+
// LayoutNode is structurally identical to the wire type.
|
|
209
|
+
nodes: nodes as unknown as GuiLayoutNode[],
|
|
210
|
+
textures: [...textures].sort(),
|
|
211
|
+
nodeCount,
|
|
212
|
+
defsFiles: files,
|
|
213
|
+
visibilityChecks: [...checks.values()].sort((a, b) => a.key.localeCompare(b.key)),
|
|
214
|
+
timings: {
|
|
215
|
+
parseMs: timing.parseMs,
|
|
216
|
+
defsMs,
|
|
217
|
+
layoutMs: timing.layoutMs,
|
|
218
|
+
totalMs: performance.now() - t0,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text metrics measured in-game for Gitan-Regular (StandardGameFont) at
|
|
3
|
+
* fontsize 15, probe batches 01-03 (docs/gui-designer/calibration/probes/).
|
|
4
|
+
*
|
|
5
|
+
* They serve twice: as the measured table of the profile whose font this is,
|
|
6
|
+
* and as the layout engine's assumption for a game whose own probe has not run
|
|
7
|
+
* yet. That is why they sit in their own module: a game's meta reads them, and
|
|
8
|
+
* the engine may not import a game profile (the games/ -> gui/ direction only).
|
|
9
|
+
*/
|
|
10
|
+
import type { GuiTextMetrics } from "./layoutEngine";
|
|
11
|
+
|
|
12
|
+
export const GITAN_MEASURED_METRICS: GuiTextMetrics = {
|
|
13
|
+
baseFontsize: 15,
|
|
14
|
+
lineHeight: 21, // B1-G
|
|
15
|
+
glyphs: {
|
|
16
|
+
M: { adv: 14, ink: 13 }, // B1-G, B2-L
|
|
17
|
+
i: { adv: 4, ink: 4 }, // B1-G, B2-L
|
|
18
|
+
" ": { adv: 4, ink: 0 }, // B3-S2
|
|
19
|
+
},
|
|
20
|
+
defaultGlyph: { adv: 9, ink: 8 }, // unmeasured average guess
|
|
21
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paradox/guiPreview`: one laid-out instance per palette entry, for a
|
|
3
|
+
* library tile. The synthetic document keeps the requested document's own
|
|
4
|
+
* declarations (template / local_template / types / type) and adds a single
|
|
5
|
+
* root instance:
|
|
6
|
+
*
|
|
7
|
+
* type or builtin -> `<name> = { }`
|
|
8
|
+
* template -> `widget = { using = <name> }`
|
|
9
|
+
* raw -> the fragment verbatim (a saved component)
|
|
10
|
+
*
|
|
11
|
+
* Layout goes through computeGuiLayout with the same store the canvas uses,
|
|
12
|
+
* so a tile shows what inserting the entry would show. No vscode, no fs.
|
|
13
|
+
*/
|
|
14
|
+
import type { GuiLayoutNode, GuiPreview, GuiPreviewEntry } from "@px-lsp/protocol/protocol";
|
|
15
|
+
import { parseScript } from "../parser";
|
|
16
|
+
import { computeGuiLayout, type LayoutEnv, type LayoutNode } from "./layoutEngine";
|
|
17
|
+
import type { GuiDefs } from "./guiDefs";
|
|
18
|
+
import { DECL_MARKERS } from "./declMarkers";
|
|
19
|
+
import { VIEWPORT } from "./layoutService";
|
|
20
|
+
|
|
21
|
+
/** The document's declarations, verbatim, so local templates and types resolve in the synthetic doc. */
|
|
22
|
+
export function declarationsOf(text: string): string {
|
|
23
|
+
const { root } = parseScript(text);
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
// `template name { }` parses as a bare `template` word followed by the
|
|
26
|
+
// `name { }` assignment; keep both, verbatim, the way the engine reads them.
|
|
27
|
+
let marker: { start: number } | null = null;
|
|
28
|
+
for (const s of root.statements) {
|
|
29
|
+
if (s.kind === "value") {
|
|
30
|
+
marker =
|
|
31
|
+
s.value.kind === "scalar" && DECL_MARKERS.has(s.value.text.toLowerCase())
|
|
32
|
+
? { start: s.range.start }
|
|
33
|
+
: null;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (marker) parts.push(text.slice(marker.start, s.range.end));
|
|
37
|
+
marker = null;
|
|
38
|
+
}
|
|
39
|
+
return parts.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function instanceFor(entry: GuiPreviewEntry): string | null {
|
|
43
|
+
switch (entry.kind) {
|
|
44
|
+
case "type":
|
|
45
|
+
case "builtin":
|
|
46
|
+
return /^[A-Za-z_][\w.]*$/.test(entry.name) ? `${entry.name} = {\n}` : null;
|
|
47
|
+
case "template":
|
|
48
|
+
return /^[A-Za-z_][\w.]*$/.test(entry.name) ? `widget = {\n\tusing = ${entry.name}\n}` : null;
|
|
49
|
+
case "raw":
|
|
50
|
+
return entry.fragment?.trim() ? entry.fragment : null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function previewEntries(
|
|
55
|
+
text: string,
|
|
56
|
+
entries: GuiPreviewEntry[],
|
|
57
|
+
defs: GuiDefs,
|
|
58
|
+
measurer: LayoutEnv | undefined
|
|
59
|
+
): GuiPreview[] {
|
|
60
|
+
const decls = declarationsOf(text);
|
|
61
|
+
return entries.map((entry) => {
|
|
62
|
+
const instance = instanceFor(entry);
|
|
63
|
+
if (!instance) return { name: entry.name, node: null, textures: [], reason: "nothing to lay out" };
|
|
64
|
+
let nodes: LayoutNode[];
|
|
65
|
+
try {
|
|
66
|
+
nodes = computeGuiLayout(`${decls}\n${instance}\n`, { defs, viewport: VIEWPORT, measurer });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
name: entry.name,
|
|
70
|
+
node: null,
|
|
71
|
+
textures: [],
|
|
72
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const node = nodes[0];
|
|
76
|
+
if (!node) return { name: entry.name, node: null, textures: [], reason: "the store has no such widget" };
|
|
77
|
+
if (node.rect.w <= 0 || node.rect.h <= 0) {
|
|
78
|
+
return { name: entry.name, node: null, textures: [], reason: "zero size without a parent" };
|
|
79
|
+
}
|
|
80
|
+
const textures = new Set<string>();
|
|
81
|
+
const visit = (n: LayoutNode): void => {
|
|
82
|
+
if (n.bg?.texture) textures.add(n.bg.texture);
|
|
83
|
+
if (n.fill?.texture) textures.add(n.fill.texture);
|
|
84
|
+
for (const c of n.children) visit(c);
|
|
85
|
+
};
|
|
86
|
+
visit(node);
|
|
87
|
+
return { name: entry.name, node: node as unknown as GuiLayoutNode, textures: [...textures].sort() };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a save game, as data: which meta keys name the campaign and its
|
|
3
|
+
* date, where the entities a preview needs sit, and which datafunction chain
|
|
4
|
+
* shows which field. A game profile carries one (`GameProfile.saveSchema`,
|
|
5
|
+
* built in games/<id>/saveSchema.ts); the reader (saveValues.ts) stays generic
|
|
6
|
+
* over it and names no game.
|
|
7
|
+
*
|
|
8
|
+
* The small readers a mapping table needs live here too, so a profile's table
|
|
9
|
+
* can be plain data sitting next to that game's other tables.
|
|
10
|
+
*
|
|
11
|
+
* Pure: parsed blocks in, display text out. No fs, no vscode.
|
|
12
|
+
*/
|
|
13
|
+
import type { BlockNode, Statement } from "../parser";
|
|
14
|
+
|
|
15
|
+
/** The blocks a preview reads, cut out of the save. */
|
|
16
|
+
export interface SaveEntities {
|
|
17
|
+
/** The save's meta block. */
|
|
18
|
+
meta?: BlockNode;
|
|
19
|
+
/** The played country's entry, for a game whose player runs one. */
|
|
20
|
+
country?: BlockNode;
|
|
21
|
+
/** The played country's ruler. */
|
|
22
|
+
ruler?: BlockNode;
|
|
23
|
+
/** The played country's heir. */
|
|
24
|
+
heir?: BlockNode;
|
|
25
|
+
/** The played country's capital. */
|
|
26
|
+
capital?: BlockNode;
|
|
27
|
+
/** The played character's own record, for a game whose player IS a character. */
|
|
28
|
+
character?: BlockNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Everything the readers may look at. An absent field means the save had none. */
|
|
32
|
+
export interface SaveContext extends SaveEntities {
|
|
33
|
+
/** The country's tag (`GBR`), when the save names its entries by one. */
|
|
34
|
+
tag?: string;
|
|
35
|
+
/** The save's date, formatted. */
|
|
36
|
+
date?: string;
|
|
37
|
+
loc: (key: string) => string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ValueMapping {
|
|
41
|
+
/** Every chain (bracket-free) that shows this value. */
|
|
42
|
+
chains: string[];
|
|
43
|
+
read: (ctx: SaveContext) => string | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The entity slots a country entry points at, filled from its own registry. */
|
|
47
|
+
export type SaveSlot = "ruler" | "heir" | "capital";
|
|
48
|
+
|
|
49
|
+
/** One id a country entry holds, and the registry that id is looked up in. */
|
|
50
|
+
export interface SaveLink {
|
|
51
|
+
slot: SaveSlot;
|
|
52
|
+
/** Key on the country entry holding the id. */
|
|
53
|
+
key: string;
|
|
54
|
+
/** Top-level registry block the entry lives in. */
|
|
55
|
+
block: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A save that keeps its entities in top-level registries, each an id-keyed
|
|
60
|
+
* table under one sub-block (`<block>.<entries>.<id>`). The played country is
|
|
61
|
+
* the entry whose tag localizes to the campaign name the meta states, and its
|
|
62
|
+
* own fields say which ids the later registries are searched for — which is why
|
|
63
|
+
* one pass in file order answers everything.
|
|
64
|
+
*/
|
|
65
|
+
export interface SaveRegistry {
|
|
66
|
+
/** Sub-block each registry keeps its entries under (`database`). */
|
|
67
|
+
entries: string;
|
|
68
|
+
/** Registry holding the country entries, one of which is the player's. */
|
|
69
|
+
countryBlock: string;
|
|
70
|
+
/** Country-entry key naming it; matched, through loc, against the meta name. */
|
|
71
|
+
tagKey: string;
|
|
72
|
+
/** Country-entry key marking a playable country, used when no name matched. */
|
|
73
|
+
mainKey: string;
|
|
74
|
+
links: SaveLink[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A save whose player is a character named at the very END of the file, long
|
|
79
|
+
* after the record a preview wants. Finding the id costs a cheap chunk scan for
|
|
80
|
+
* the marker before the streaming pass that cuts the record out.
|
|
81
|
+
*/
|
|
82
|
+
export interface SavePlayerRecord {
|
|
83
|
+
/** Line-start marker opening the block that names the played record. */
|
|
84
|
+
marker: string;
|
|
85
|
+
/** Key inside that block holding the record's id. */
|
|
86
|
+
idKey: string;
|
|
87
|
+
/** Top-level block whose `<id>` entry is the played record. */
|
|
88
|
+
block: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** How one game's saves are read. */
|
|
92
|
+
export interface SaveSchema {
|
|
93
|
+
/** Meta key holding the save's date. Absent = `game_date`. */
|
|
94
|
+
dateKey?: string;
|
|
95
|
+
/** Meta key holding the campaign or player name. Absent = `name`. */
|
|
96
|
+
nameKey?: string;
|
|
97
|
+
/** The curated chain -> value table. */
|
|
98
|
+
mappings: ValueMapping[];
|
|
99
|
+
/**
|
|
100
|
+
* Chains the save's own contents name rather than the table (a character's
|
|
101
|
+
* stored variables), read per save. Absent = the table is all there is.
|
|
102
|
+
*/
|
|
103
|
+
expand?: (ctx: SaveContext) => Record<string, string>;
|
|
104
|
+
/** Set when the save keeps its entities in id-keyed registries. */
|
|
105
|
+
registry?: SaveRegistry;
|
|
106
|
+
/** Set when the played character is named at the end of the save. */
|
|
107
|
+
record?: SavePlayerRecord;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// The readers a mapping table is written with
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export function statements(node: BlockNode | undefined): Statement[] {
|
|
115
|
+
return node?.statements ?? [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The scalar value of `key` in a block. */
|
|
119
|
+
export function scalar(node: BlockNode | undefined, key: string): string | undefined {
|
|
120
|
+
for (const s of statements(node)) {
|
|
121
|
+
if (s.kind === "assignment" && s.key.text === key && s.value?.kind === "scalar") {
|
|
122
|
+
return s.value.text;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The block value of `key` in a block. */
|
|
129
|
+
export function block(node: BlockNode | undefined, key: string): BlockNode | undefined {
|
|
130
|
+
for (const s of statements(node)) {
|
|
131
|
+
if (s.kind === "assignment" && s.key.text === key && s.value?.kind === "block") {
|
|
132
|
+
return s.value;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A name is either literal text or a loc key; a key that resolves wins. */
|
|
139
|
+
export function named(ctx: SaveContext, raw: string | undefined): string | undefined {
|
|
140
|
+
if (!raw) return undefined;
|
|
141
|
+
return /^[A-Za-z0-9_.]+$/.test(raw) ? (ctx.loc(raw) ?? raw) : raw;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `1247181.89015` -> `1,247,182`: money shows whole and grouped. */
|
|
145
|
+
export function thousands(raw: string | undefined): string | undefined {
|
|
146
|
+
const n = raw === undefined ? NaN : Number(raw);
|
|
147
|
+
if (!Number.isFinite(n)) return undefined;
|
|
148
|
+
return Math.round(n)
|
|
149
|
+
.toString()
|
|
150
|
+
.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const MONTHS = [
|
|
154
|
+
"January",
|
|
155
|
+
"February",
|
|
156
|
+
"March",
|
|
157
|
+
"April",
|
|
158
|
+
"May",
|
|
159
|
+
"June",
|
|
160
|
+
"July",
|
|
161
|
+
"August",
|
|
162
|
+
"September",
|
|
163
|
+
"October",
|
|
164
|
+
"November",
|
|
165
|
+
"December",
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
/** `1836.1.21` -> `[1836, 1, 21]`; anything else is not a date. */
|
|
169
|
+
function parseDate(raw: string | undefined): [number, number, number] | undefined {
|
|
170
|
+
const m = raw === undefined ? null : /^(\d+)\.(\d+)\.(\d+)/.exec(raw);
|
|
171
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `1836.1.21` -> `21 January 1836`; anything else comes back verbatim. */
|
|
175
|
+
export function formatDate(raw: string | undefined): string {
|
|
176
|
+
if (!raw) return "";
|
|
177
|
+
const d = parseDate(raw);
|
|
178
|
+
const month = d ? MONTHS[d[1] - 1] : undefined;
|
|
179
|
+
return d && month ? `${d[2]} ${month} ${d[0]}` : raw;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whole years between a birth date and the save's date. */
|
|
183
|
+
export function age(birth: string | undefined, today: string | undefined): string | undefined {
|
|
184
|
+
const b = parseDate(birth);
|
|
185
|
+
const t = parseDate(today);
|
|
186
|
+
if (!b || !t) return undefined;
|
|
187
|
+
const beforeBirthday = t[1] < b[1] || (t[1] === b[1] && t[2] < b[2]);
|
|
188
|
+
const years = t[0] - b[0] - (beforeBirthday ? 1 : 0);
|
|
189
|
+
return years >= 0 ? String(years) : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// The default schema
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What any Jomini save answers out of its meta block alone. Every reader
|
|
198
|
+
* tolerates absence, so a game whose profile carries no schema of its own still
|
|
199
|
+
* gets exactly these rows.
|
|
200
|
+
*/
|
|
201
|
+
export const META_MAPPINGS: ValueMapping[] = [
|
|
202
|
+
{
|
|
203
|
+
chains: ["GetPlayer.GetName", "GetPlayer.GetCountry.GetName"],
|
|
204
|
+
read: (ctx) => scalar(ctx.meta, "name"),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
chains: ["GetPlayer.GetRank", "GetPlayer.GetCountryRank.GetName"],
|
|
208
|
+
read: (ctx) => {
|
|
209
|
+
const rank = scalar(ctx.meta, "rank");
|
|
210
|
+
return rank ? (ctx.loc(rank) ?? rank) : undefined;
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
chains: ["GetCurrentDate", "GetGameDate"],
|
|
215
|
+
read: (ctx) => ctx.date,
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/** The schema a profile that carries none of its own is read with. */
|
|
220
|
+
export const DEFAULT_SAVE_SCHEMA: SaveSchema = { mappings: META_MAPPINGS };
|