@px-lsp/server 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/data/ck3/skeletons.json +1 -0
  3. package/data/eu5/skeletons.json +1 -0
  4. package/data/vic3/skeletons.json +1 -0
  5. package/dist/browser-data/ck3/docs.json +1 -1
  6. package/dist/browser-data/ck3/tokens.json +1 -1
  7. package/dist/browser-data/vic3/tokens.json +1 -1
  8. package/dist/browser.js +50 -37
  9. package/dist/server.js +2000 -352
  10. package/dist/types/packages/server/src/features/blockSnippets.d.ts +20 -4
  11. package/dist/types/packages/server/src/features/completion.d.ts +11 -0
  12. package/dist/types/packages/server/src/features/definitionSkeletons.d.ts +24 -0
  13. package/dist/types/packages/server/src/games/ck3/schema.d.ts +1 -1
  14. package/dist/types/packages/server/src/games/eu5/index.d.ts +1 -1
  15. package/dist/types/packages/server/src/games/profile.d.ts +85 -1
  16. package/dist/types/packages/server/src/schema/skeletons.d.ts +102 -0
  17. package/dist/types/packages/server/src/schema/types.d.ts +30 -0
  18. package/package.json +3 -3
  19. package/src/coa/coa.ts +31 -0
  20. package/src/coa/coaDesigner.ts +131 -0
  21. package/src/coa/coaParse.ts +3 -0
  22. package/src/creators/definitionEdit.ts +198 -0
  23. package/src/creators/definitionForm.ts +479 -0
  24. package/src/creators/modifierFormats.ts +0 -0
  25. package/src/data/docsParser.ts +21 -4
  26. package/src/features/blockSnippets.ts +151 -20
  27. package/src/features/calendarDates.ts +8 -5
  28. package/src/features/completion.ts +38 -4
  29. package/src/features/definitionSkeletons.ts +109 -0
  30. package/src/features/inlayHints.ts +5 -2
  31. package/src/features/locText.ts +223 -0
  32. package/src/features/snippetList.ts +83 -0
  33. package/src/games/ck3/index.ts +16 -0
  34. package/src/games/ck3/meta.ts +81 -1
  35. package/src/games/ck3/schema.ts +83 -7
  36. package/src/games/ck3/structures.ts +37 -0
  37. package/src/games/eu5/index.ts +7 -1
  38. package/src/games/eu5/meta.ts +5 -1
  39. package/src/games/profile.ts +77 -1
  40. package/src/games/vic3/index.ts +4 -0
  41. package/src/games/vic3/meta.ts +5 -1
  42. package/src/gui/sourceModel.ts +51 -8
  43. package/src/gui/textResolve.ts +3 -3
  44. package/src/overview/dynastyTree.ts +493 -0
  45. package/src/overview/eventGraph.ts +21 -1
  46. package/src/overview/eventVocabulary.ts +33 -33
  47. package/src/overview/exampleWiki.ts +4 -1
  48. package/src/schema/loader.ts +2 -1
  49. package/src/schema/skeletons.ts +182 -0
  50. package/src/schema/types.ts +30 -0
  51. package/src/server.ts +147 -6
@@ -0,0 +1,223 @@
1
+ /**
2
+ * paradox/locText: a localization value as the PLAYER reads it.
3
+ *
4
+ * paradox/lookupLoc answers the value verbatim, which is what an editor needs.
5
+ * A panel that SHOWS the value needs the sentence. Measured over the 609
6
+ * `culture_parameter_*` values one game ships: 280 carry a real datafunction
7
+ * call, 145 of them the single shape
8
+ * `[GetTrait('rough_terrain_expert').GetName( GetNullCharacter )]`, and 130
9
+ * values nest another key as `$key$`. A form that prints those verbatim shows
10
+ * the modder brackets instead of "The Rough Terrain Expert Commander Trait is
11
+ * more common".
12
+ *
13
+ * Nothing here is written for one game. The words come from the loc index, the
14
+ * KIND a `Get<Something>('name')` chain names comes from the definition index,
15
+ * and the loc key that kind's names take comes from the active profile's
16
+ * schema. A profile whose schema knows the kind resolves the chain; one whose
17
+ * schema does not falls back to the definition name and says so, which is the
18
+ * same behavior for every game without a line of per-game code.
19
+ *
20
+ * No `vscode` imports: unit-tested in plain Node.
21
+ */
22
+ import type { LocTextResult, LocTextValue } from "@px-lsp/protocol/protocol";
23
+ import { chipFor, stripFormatting, tokenize } from "../gui/textResolve";
24
+
25
+ export interface LocTextDeps {
26
+ /** The configured language's value for a loc key, or undefined. */
27
+ loc(key: string): string | undefined;
28
+ /**
29
+ * The definition kinds a name is indexed under, shadow-resolved, WITHOUT the
30
+ * loc_key kind (every name a value mentions is also a loc key).
31
+ */
32
+ kindsOf(name: string): string[];
33
+ /**
34
+ * The loc key patterns a kind's names take (`$` = the name), most specific
35
+ * first, straight off the schema entry. An unknown kind has none.
36
+ */
37
+ patternsOf(kind: string): string[];
38
+ }
39
+
40
+ /**
41
+ * The value, plus one level of whatever it inlines. A `$key$` hop or a
42
+ * resolved chain lands on another value that may itself hold markup (the
43
+ * `court_physician` key IS a `[GetCourtPositionType(...).GetName()]` call),
44
+ * and one more pass turns that into words. Deeper is the game's own business.
45
+ */
46
+ const MAX_DEPTH = 2;
47
+
48
+ /**
49
+ * `$key$`, with an optional format spec (`$VALUE|0$`). A key the loc index has
50
+ * is substituted; anything else stays verbatim and marks the value unresolved,
51
+ * because a slot the panel cannot fill is not a word.
52
+ */
53
+ const NESTED_KEY = /\$([A-Za-z0-9_.\-']+)(?:\|[^$]*)?\$/g;
54
+
55
+ /** `[prestige_i]`: an icon the game draws and plain text has no glyph for. */
56
+ const ICON_TAG = /^[a-z0-9_]+_i$/;
57
+
58
+ /** `[culture|E]`: a link to a game concept, shown as that concept's word. */
59
+ const CONCEPT_LINK = /^([A-Za-z0-9_]+)\|[A-Za-z]+$/;
60
+
61
+ /** `Localize('k')` / `Concept('k')` / `Concept('k','shown')`. */
62
+ const LOCALIZE_CALL = /^(?:Localize|Concept)\s*\(\s*'([^']*)'(?:\s*,\s*'([^']*)')?\s*\)$/;
63
+
64
+ /**
65
+ * `SelectLocalization( HasDlcFeature('x'), 'on', 'off' )`: the game picks by a
66
+ * condition the server cannot evaluate. The first branch is the one the
67
+ * feature-on player reads, which is what a creator preview should show.
68
+ */
69
+ const SELECT_CALL = /^SelectLocalization\s*\(.*?,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)$/s;
70
+
71
+ /**
72
+ * `Get<Something>('name')` followed by member hops:
73
+ * `GetTrait('brave').GetName( GetNullCharacter )`, `GetMaA('bowmen').GetName`,
74
+ * `GetCourtPositionType('x').GetName()`.
75
+ */
76
+ const DEF_CHAIN = /^Get(\w+)\s*\(\s*'([^']+)'\s*\)\s*((?:\.\s*\w+\s*(?:\([^()]*\))?\s*)*)$/;
77
+
78
+ /** The members that mean "the name the player reads", measured in the corpus. */
79
+ const NAME_MEMBERS = new Set(["GetName", "GetTypeName", "GetNameNoTooltip"]);
80
+
81
+ /** The concept kind's own schema entry states the key a `[x|E]` link reaches. */
82
+ const CONCEPT_KIND = "game_concept";
83
+
84
+ /** `men_at_arms` -> `MenAtArms`, the spelling a datafunction name would use. */
85
+ function pascal(kind: string): string {
86
+ return kind
87
+ .split("_")
88
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
89
+ .join("");
90
+ }
91
+
92
+ /** The key patterns to try for a kind, most specific first, `$` last. */
93
+ function patternsWithFallback(kind: string | null, deps: LocTextDeps): string[] {
94
+ const own = kind === null ? [] : deps.patternsOf(kind);
95
+ return [...new Set([...own, "$"])];
96
+ }
97
+
98
+ /** The first pattern that resolves for `name`, or undefined. */
99
+ function locOfName(name: string, kind: string | null, deps: LocTextDeps): string | undefined {
100
+ for (const pattern of patternsWithFallback(kind, deps)) {
101
+ const value = deps.loc(pattern.replace("$", name));
102
+ if (value !== undefined && value !== "") return value;
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /**
108
+ * The kinds to try for `Get<Head>('name')`, best first: a kind whose PascalCase
109
+ * spelling the function name carries (`GetCourtPositionType` -> `court_position`)
110
+ * outranks the rest, and a name the index does not know still gets the bare
111
+ * `$` pattern from `patternsWithFallback`.
112
+ */
113
+ function candidateKinds(head: string, name: string, deps: LocTextDeps): (string | null)[] {
114
+ const kinds = deps.kindsOf(name);
115
+ const named = kinds.filter((kind) => head.includes(pascal(kind)));
116
+ const rest = kinds.filter((kind) => !named.includes(kind));
117
+ return [...named, ...rest, null];
118
+ }
119
+
120
+ interface Rendered {
121
+ text: string;
122
+ resolved: boolean;
123
+ }
124
+
125
+ /**
126
+ * A value a call resolved to, as words. One more pass turns its own markup
127
+ * into text; past {@link MAX_DEPTH} the formatting is stripped and the rest
128
+ * kept, so the recursion cannot run away on a self-referencing key.
129
+ */
130
+ function inline(value: string, deps: LocTextDeps, depth: number): Rendered {
131
+ if (depth + 1 >= MAX_DEPTH) return { text: stripFormatting(value).trim(), resolved: true };
132
+ return render(value, deps, depth + 1);
133
+ }
134
+
135
+ /** One `[ ... ]` expression as words, and whether it became any. */
136
+ function renderFn(fn: string, deps: LocTextDeps, depth: number): Rendered {
137
+ // `|0`, `|V`, `|E`: format specifiers after the chain. The concept-link form
138
+ // needs its suffix, so it is matched before they are dropped.
139
+ const concept = CONCEPT_LINK.exec(fn.trim());
140
+ if (concept) {
141
+ const value = locOfName(concept[1], CONCEPT_KIND, deps);
142
+ return value === undefined ? { text: concept[1], resolved: false } : inline(value, deps, depth);
143
+ }
144
+
145
+ const source = fn.split("|")[0].trim();
146
+ if (ICON_TAG.test(source)) return { text: "", resolved: true };
147
+
148
+ const localize = LOCALIZE_CALL.exec(source);
149
+ if (localize) {
150
+ const key = localize[2] ?? localize[1];
151
+ const value = deps.loc(key);
152
+ return value === undefined ? { text: key, resolved: false } : inline(value, deps, depth);
153
+ }
154
+
155
+ const select = SELECT_CALL.exec(source);
156
+ if (select) {
157
+ const value = deps.loc(select[1]);
158
+ return value === undefined ? { text: select[1], resolved: false } : inline(value, deps, depth);
159
+ }
160
+
161
+ const chain = DEF_CHAIN.exec(source);
162
+ const members = chain ? [...chain[3].matchAll(/\.\s*(\w+)/g)].map((m) => m[1]) : [];
163
+ if (chain && members.length > 0 && NAME_MEMBERS.has(members[members.length - 1])) {
164
+ const name = chain[2];
165
+ for (const kind of candidateKinds(chain[1], name, deps)) {
166
+ const value = locOfName(name, kind, deps);
167
+ if (value !== undefined) return inline(value, deps, depth);
168
+ }
169
+ return { text: name, resolved: false };
170
+ }
171
+
172
+ // Anything else is a value only the running game has. The chain's last word
173
+ // is what the preview shows, never an invented one.
174
+ return { text: chipFor(source), resolved: false };
175
+ }
176
+
177
+ /** `$key$` hops, one level per pass; an unfilled slot stays and marks the value. */
178
+ function substituteKeys(s: string, deps: LocTextDeps, resolved: { ok: boolean }): string {
179
+ return s.replace(NESTED_KEY, (whole, key: string) => {
180
+ const value = deps.loc(key);
181
+ if (value === undefined) {
182
+ resolved.ok = false;
183
+ return whole;
184
+ }
185
+ return value;
186
+ });
187
+ }
188
+
189
+ function render(raw: string, deps: LocTextDeps, depth: number): Rendered {
190
+ const state = { ok: true };
191
+ const parts = tokenize(substituteKeys(raw, deps, state)).map((part) => {
192
+ if (part.fn === undefined) return { text: stripFormatting(part.literal ?? ""), resolved: true };
193
+ const out = renderFn(part.fn, deps, depth);
194
+ if (!out.resolved) state.ok = false;
195
+ return out;
196
+ });
197
+ // A dropped icon leaves the space on both sides of it behind.
198
+ const text = parts
199
+ .map((part) => part.text)
200
+ .join("")
201
+ .replace(/[ \t]{2,}/g, " ")
202
+ .trim();
203
+ return { text, resolved: state.ok };
204
+ }
205
+
206
+ /** One value rendered; exported for the unit test and for other server features. */
207
+ export function renderLocValue(raw: string, deps: LocTextDeps): LocTextValue {
208
+ const out = render(raw, deps, 0);
209
+ return { raw, text: out.text, resolved: out.resolved };
210
+ }
211
+
212
+ export function computeLocText(keys: readonly string[], deps: LocTextDeps): LocTextResult {
213
+ const values: Record<string, LocTextValue> = {};
214
+ for (const key of keys) {
215
+ if (key in values) continue;
216
+ const raw = deps.loc(key);
217
+ // A key the loc index cannot find is absent: the client already shows the
218
+ // key itself there, and an empty string would read as a defined blank.
219
+ if (raw === undefined) continue;
220
+ values[key] = renderLocValue(raw, deps);
221
+ }
222
+ return { values };
223
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The `paradox/snippets` answer: everything a host can offer to insert at a
3
+ * cursor in one script document.
4
+ *
5
+ * Two sources, both derived. The document's folder decides its definition kind,
6
+ * and that kind's measured skeleton (schema/skeletons.ts) supplies the
7
+ * definition and its child blocks. The cursor's block context decides which
8
+ * engine triggers/effects are legal there, and each of those contributes the
9
+ * block form of the `usage:` example its own script_docs entry ships
10
+ * (features/blockSnippets.ts). Nothing is added that the game did not state.
11
+ */
12
+ import type { SnippetItem } from "@px-lsp/protocol/protocol";
13
+ import type { TokenData } from "@px-lsp/protocol/types";
14
+ import { detectContextFromParse } from "../context";
15
+ import type { ParseResult } from "../parser";
16
+ import type { KindSkeleton } from "../schema/skeletons";
17
+ import { blockTemplateFor } from "./blockSnippets";
18
+ import { skeletonsFor } from "./definitionSkeletons";
19
+
20
+ /**
21
+ * Engine tokens the answer carries. A picker filters as the user types, so the
22
+ * cap is only about payload: the list is frequency-ordered, and past ~60 the
23
+ * tail is tokens nobody reaches for by name.
24
+ */
25
+ const MAX_TOKENS = 60;
26
+
27
+ export function buildSnippetList(
28
+ parse: ParseResult,
29
+ offset: number,
30
+ kind: string | null,
31
+ skeletons: Record<string, KindSkeleton> | undefined,
32
+ tokens: TokenData[],
33
+ counts: Record<string, number>
34
+ ): SnippetItem[] {
35
+ const out: SnippetItem[] = kind
36
+ ? skeletonsFor(parse, kind, skeletons).map((offer) => ({
37
+ id: offer.id,
38
+ label: offer.label,
39
+ detail: offer.detail,
40
+ form: offer.form,
41
+ snippet: offer.text.snippet,
42
+ plain: offer.text.plain,
43
+ }))
44
+ : [];
45
+
46
+ const { context } = detectContextFromParse(parse, offset);
47
+ const seen = new Set<string>();
48
+ const candidates: Array<{ token: TokenData; count: number }> = [];
49
+ for (const token of tokens) {
50
+ // The same context gate key-position completion applies: a trigger block
51
+ // takes no effects and an effect block takes no triggers.
52
+ if (context === "trigger" && (token.kind === "effect" || token.kind === "modifier")) continue;
53
+ if (context === "effect" && (token.kind === "trigger" || token.kind === "modifier")) continue;
54
+ if (seen.has(token.name)) continue;
55
+ if (!blockTemplateFor(token)) continue;
56
+ seen.add(token.name);
57
+ candidates.push({ token, count: counts[token.name] ?? 0 });
58
+ }
59
+ candidates.sort((a, b) => b.count - a.count || (a.token.name < b.token.name ? -1 : 1));
60
+ for (const { token } of candidates.slice(0, MAX_TOKENS)) {
61
+ const template = blockTemplateFor(token)!;
62
+ out.push({
63
+ id: token.name,
64
+ label: token.name,
65
+ detail: `${token.kind} block, from the game's own usage example`,
66
+ form: "token",
67
+ snippet: template.snippet,
68
+ plain: template.plain,
69
+ });
70
+ // An example that marks fields `# optional` offers both: the required
71
+ // fields, and the whole thing. The pair costs one cap slot, not two.
72
+ if (template.full)
73
+ out.push({
74
+ id: `${token.name}.full`,
75
+ label: `${token.name} (all fields)`,
76
+ detail: `${token.kind} block with its optional fields, from the game's own usage example`,
77
+ form: "token",
78
+ snippet: template.full.snippet,
79
+ plain: template.full.plain,
80
+ });
81
+ }
82
+ return out;
83
+ }
@@ -3,6 +3,7 @@
3
3
  * and bundled-data import, assembled behind the GameProfile interface.
4
4
  */
5
5
  import type { GameProfile } from "../profile";
6
+ import type { SkeletonData } from "../../schema/skeletons";
6
7
  import { ck3Meta } from "./meta";
7
8
  import { BLOCK_REF_FIELDS, CK3_SCHEMA, PREFIX_REFS, REF_FIELDS } from "./schema";
8
9
  import { STRUCTURE_SOURCES } from "./structures";
@@ -13,6 +14,8 @@ import { CK3_SAVE_SCHEMA } from "./saveSchema";
13
14
  // and scripts/build-gui-schema.ts for regeneration.
14
15
  import BUNDLED_DATA_TYPES from "../../../data/ck3/dataTypes.json";
15
16
  import GUI_SCHEMA from "../../../data/ck3/guiSchema.json";
17
+ // Definition skeletons measured over the vanilla tree (scripts/build-skeletons.ts).
18
+ import SKELETONS from "../../../data/ck3/skeletons.json";
16
19
 
17
20
  export const ck3Profile: GameProfile = {
18
21
  ...ck3Meta,
@@ -30,8 +33,21 @@ export const ck3Profile: GameProfile = {
30
33
  scripted_gui: { key: "scope", default: "character" },
31
34
  },
32
35
  modifierPlaceholders: CK3_MODIFIER_PLACEHOLDERS,
36
+ // The three triggers the measured condition shapes need. Over the game's own
37
+ // 21 dynasty legacy tracks every `is_shown` opens with `has_dlc_feature`, and
38
+ // over its 105 dynasty perks every `can_be_picked` is either
39
+ // `has_dlc_feature = <feature>` (10) or `<scripted trigger> = yes` (44).
40
+ conditionValues: {
41
+ // triggers.log enumerates the whole set on the trigger itself.
42
+ has_dlc_feature: { from: "docList" },
43
+ // `has_game_rule = unrestricted_dynasty_legacies_all` names a SETTING, and
44
+ // a setting is an inner block of a game_rule (00_game_rules.txt).
45
+ has_game_rule: { from: "innerKeys", kind: "game_rule", except: ["categories", "default"] },
46
+ scripted_trigger: { from: "kind", kind: "scripted_trigger" },
47
+ },
33
48
  bundledDataTypes: BUNDLED_DATA_TYPES,
34
49
  guiSchema: GUI_SCHEMA,
50
+ skeletons: (SKELETONS as unknown as SkeletonData).kinds,
35
51
  saveSchema: CK3_SAVE_SCHEMA,
36
52
  wikiNote: "Source: CK3 wiki (may lag behind the current game version)",
37
53
  diagnosticSource: "ck3-script",
@@ -12,9 +12,12 @@ export const ck3Meta: GameMeta = {
12
12
  shortName: "CK3",
13
13
  engine: "jomini",
14
14
  descriptor: "mod",
15
- configDirName: ".ck3modding",
15
+ configDirName: ".px-toolkit",
16
+ legacyConfigDirName: ".ck3modding",
16
17
  docsFolderName: "Crusader Kings III",
17
18
  steamAppId: 1158310,
19
+ // Verified on the 1.19 install: dlc_001.dds .. dlc_029.dds.
20
+ dlcIconDir: "gfx/interface/icons/dlc",
18
21
  eventNamespaces: true,
19
22
  // The engine's own default metrics: they were measured on this game's font,
20
23
  // and the layout engine reuses them for games whose probe has not run.
@@ -22,6 +25,60 @@ export const ck3Meta: GameMeta = {
22
25
  guiTextMetrics: GITAN_MEASURED_METRICS,
23
26
  scaffolds: CK3_SCAFFOLDS,
24
27
  tiger: { binaryName: "ck3-tiger", repoSlug: "amtep/tiger", confName: "ck3-tiger.conf" },
28
+ // Visual creators. Every row is backed by a folder the schema table already
29
+ // indexes and a shape read out of the game's own files:
30
+ // trait common/traits, documented by common/traits/_traits.info
31
+ // dynasty_legacy common/dynasty_legacies, per _dynasty_legacies.info
32
+ // ("Dynasty Legacies are containers for perks"); the perks
33
+ // themselves live in common/dynasty_perks (`legacy = <track>`)
34
+ // culture common/culture/cultures (00_arabic.txt: color, ethos,
35
+ // heritage, language, traditions, name_list, parents…)
36
+ // culture_tradition common/culture/traditions, whose _traditions.info adds
37
+ // `category` and `layers` to the shape _cultural_traits.info
38
+ // documents for every cultural trait
39
+ // dynasty_tree NOT a definition kind: history/characters linked by
40
+ // father/mother/dynasty/house (history/characters/*.txt)
41
+ creators: [
42
+ {
43
+ kind: "trait",
44
+ label: "Trait Creator",
45
+ icon: "sparkles",
46
+ tip: "Design a character trait and write it into the mod.",
47
+ },
48
+ {
49
+ kind: "dynasty_legacy",
50
+ label: "Dynasty Legacy Creator",
51
+ icon: "layers",
52
+ tip: "Build a legacy track and its perks.",
53
+ },
54
+ {
55
+ kind: "culture",
56
+ label: "Culture Creator",
57
+ icon: "globe",
58
+ tip: "Compose a culture from the game's own pillars and traditions.",
59
+ },
60
+ {
61
+ kind: "culture_tradition",
62
+ label: "Tradition Creator",
63
+ icon: "flame",
64
+ tip: "Design a culture tradition, its layered icon and its modifiers.",
65
+ },
66
+ {
67
+ kind: "dynasty_tree",
68
+ label: "Dynasty Tree",
69
+ icon: "users",
70
+ tip: "See and edit a dynasty's characters across history.",
71
+ },
72
+ ],
73
+ // Measured on the 1.19 install: common/modifier_definition_formats/ holds 13
74
+ // files documented by _definitions.info in the same folder (decimals, color,
75
+ // prefix/suffix/negative_suffix as loc keys, percent, already_percent,
76
+ // hidden, no_difference_sign), and gui/texticons.gui holds the `texticon`
77
+ // blocks a `[gold_i]` in one of those loc values resolves to.
78
+ modifierFormats: {
79
+ folder: "common/modifier_definition_formats",
80
+ textIcons: "gui/texticons.gui",
81
+ },
25
82
  // Date-format keys verified in ck3.exe 1.19 (the gamedate.cpp string block);
26
83
  // formats mirror game/localization/english/core_l_english.yml. Vanilla
27
84
  // appends $ERA$/$ERA_BCE$ itself - dropped here, the {era} slot replaces it.
@@ -64,4 +121,27 @@ export const ck3Meta: GameMeta = {
64
121
  // "" keeps the pre-profile cache filenames (docsCache.json, vanillaIndex-*.json)
65
122
  // so existing users' caches survive the M2 restructure.
66
123
  cacheSuffix: "",
124
+ // Coat-of-arms coverage measured against this install (1.19.0.6, 2026-09-03):
125
+ // parseCoaFile reads all 2992 vanilla definitions in the 10 files of
126
+ // common/coat_of_arms/coat_of_arms with 0 parse errors, and 100% of both the
127
+ // 7800 texture references (1629 files, every one decoding) and the 15327
128
+ // colors resolve, the named ones out of the 112 in common/named_colors. 239
129
+ // flags (8.0%) carried something the model dropped, 387 of those 399 keys
130
+ // being `depth` on an instance, which the model now carries; Vic3, which has
131
+ // shipped the builder since 0.3.2, drops something on 8.3% of its flags, so
132
+ // CK3 is no worse off.
133
+ flagBuilder: true,
134
+ // Measured on this install (1.19.0.6, 2026-09-03): the in-game designer's
135
+ // own files are all present and parse - 38 visible patterns of 42 rows in
136
+ // gfx/coat_of_arms/patterns/50_coa_designer_patterns.txt, 1576 visible
137
+ // emblems of 1578 rows in 13 categories in
138
+ // colored_emblems/50_coa_designer_emblems.txt, 13 palette colors in
139
+ // color_palettes/50_coa_designer_palettes.txt, 35 whole layouts in
140
+ // emblem_layouts/50_coa_designer_emblem_layouts.txt, the
141
+ // `coa_designer_blank_default` template in
142
+ // common/coat_of_arms/coat_of_arms/99_coa_designer_templates.txt and 35
143
+ // preview frames under gfx/interface/coat_of_arms. Victoria 3 ships no
144
+ // gfx/coat_of_arms/color_palettes or emblem_layouts at all, and EU5 no coa
145
+ // designer either, so neither sets this.
146
+ coaDesigner: true,
67
147
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The bundled CK3 schema table (rework plan AD-3). One entry per game folder
3
3
  * we understand; community-editable in-repo, extendable per-workspace via
4
- * .ck3modding/schema.json.
4
+ * .px-toolkit/schema.json.
5
5
  *
6
6
  * Every entry was verified against a real CK3 install (game version with
7
7
  * common/ ~150 subfolders): the folder was listed, a file opened, and the
@@ -29,7 +29,20 @@ const CK3_SCHEMA_BASE: SchemaEntry[] = [
29
29
  { path: "localization", kind: "loc_key", ext: ".yml", extraction: "loc-key" },
30
30
 
31
31
  // --- Characters, traits, interactions ---
32
- { path: "common/traits", kind: "trait", requiredLoc: ["trait_$"], rootScopes: ["character"] },
32
+ // locPatterns and iconFolder verbatim from common/traits/_traits.info
33
+ // ("== Loc/icon ==": "the name key is trait_<key>, and the desc key is
34
+ // trait_<key>_desc", "The default icon path is
35
+ // gfx/interface/icons/traits/<trait>.dds"). trait_$_desc stays OUT of
36
+ // requiredLoc: schemaVanilla.test.ts measures it at 90.4% of vanilla traits,
37
+ // under the 95% bar a missing-loc diagnostic needs.
38
+ {
39
+ path: "common/traits",
40
+ kind: "trait",
41
+ requiredLoc: ["trait_$"],
42
+ locPatterns: ["trait_$", "trait_$_desc"],
43
+ iconFolder: "gfx/interface/icons/traits",
44
+ rootScopes: ["character"],
45
+ },
33
46
  {
34
47
  path: "common/character_interactions",
35
48
  kind: "character_interaction",
@@ -97,13 +110,43 @@ const CK3_SCHEMA_BASE: SchemaEntry[] = [
97
110
  { path: "common/court_types", kind: "court_type" },
98
111
 
99
112
  // --- Culture ---
100
- { path: "common/culture/cultures", kind: "culture", requiredLoc: ["$"] },
101
- { path: "common/culture/pillars", kind: "culture_pillar" },
113
+ {
114
+ path: "common/culture/cultures",
115
+ kind: "culture",
116
+ requiredLoc: ["$"],
117
+ // Every one of the 244 vanilla cultures defines all three keys in
118
+ // game/localization/english/culture/cultures_l_english.yml (`bedouin`,
119
+ // `bedouin_prefix`, `bedouin_collective_noun`), measured 244/244 in
120
+ // 2026-09. requiredLoc keeps only `$` because the diagnostic that hangs
121
+ // off it is the conservative one; a creator writes the whole set.
122
+ locPatterns: ["$", "$_prefix", "$_collective_noun"],
123
+ },
124
+ {
125
+ path: "common/culture/pillars",
126
+ kind: "culture_pillar",
127
+ // One folder, five families: game/common/culture/pillars/_pillars.info
128
+ // documents `type = ethos/heritage/language/martial` on top of the common
129
+ // cultural-trait shape, and the files spell it that way
130
+ // (00_ethos.txt `type = ethos`, 00_martial_custom.txt `type =
131
+ // martial_custom`, 00_head_determination.txt `type = head_determination`).
132
+ groupKey: "type",
133
+ // Measured 2026-09-04 in game/localization/english against the 162 vanilla
134
+ // pillars: `$_name` 160, `$_desc` 9, bare `$` 4. The _pillars.info doc
135
+ // names only the generic-desc fallback, so the name pattern is a measurement.
136
+ requiredLoc: ["$_name"],
137
+ locPatterns: ["$_name", "$_desc"],
138
+ },
102
139
  {
103
140
  path: "common/culture/traditions",
104
141
  kind: "culture_tradition",
105
142
  // def names already carry the `tradition_` prefix; loc key is `<name>_name`.
106
143
  requiredLoc: ["$_name"],
144
+ // All 196 vanilla traditions also define `$_desc`, measured 196/196 in
145
+ // game/localization/english against common/culture/traditions in 2026-09
146
+ // (`tradition_winter_warriors_name` / `_desc`). requiredLoc keeps only
147
+ // `$_name` because the diagnostic that hangs off it is the conservative
148
+ // one; a creator writes the whole set.
149
+ locPatterns: ["$_name", "$_desc"],
107
150
  rootScopes: ["culture"],
108
151
  },
109
152
  { path: "common/culture/innovations", kind: "innovation", requiredLoc: ["$"], rootScopes: ["culture"] },
@@ -125,8 +168,33 @@ const CK3_SCHEMA_BASE: SchemaEntry[] = [
125
168
  // dynasties top-level keys are numeric ids; huge and never typed → not completable.
126
169
  { path: "common/dynasties", kind: "dynasty", completable: false },
127
170
  { path: "common/dynasty_houses", kind: "dynasty_house", completable: false },
128
- { path: "common/dynasty_legacies", kind: "dynasty_legacy" },
129
- { path: "common/dynasty_perks", kind: "dynasty_perk" },
171
+ // _dynasty_legacies.info, "Generated loc keys: key + "_name"". All 21 vanilla
172
+ // legacies define it (measured 2026-09-03), so it clears the requiredLoc bar.
173
+ // $_desc is not in the .info doc, but all 21 vanilla tracks define it
174
+ // (localization/english/dynasty_legacies/legacies_l_english.yml, e.g.
175
+ // blood_legacy_track_desc), so a creator asks for it too; it stays out of
176
+ // requiredLoc, where only the keys the doc itself states belong.
177
+ // iconFolder: window_dynasty_legacy.gui draws the picture from
178
+ // "[DynastyLegacy.GetIcon]", a code-side path with no script key behind it,
179
+ // and the files it finds are gfx/interface/icons/dynasty/<track key>.dds
180
+ // (22 files for the 21 tracks). Name-derived, so a creator writes the
181
+ // picture under the track's own name and never a path into the block.
182
+ // A track reads a SECOND name-derived picture, the window's illustration at
183
+ // gfx/interface/illustrations/legacy_tracks/<key>.dds ("[DynastyLegacy.
184
+ // GetTrackIcon]", 21 files); iconFolder holds one path, so that one is left
185
+ // to ck3-tiger, which names it as a missing-file warning on the track.
186
+ {
187
+ path: "common/dynasty_legacies",
188
+ kind: "dynasty_legacy",
189
+ requiredLoc: ["$_name"],
190
+ locPatterns: ["$_name", "$_desc"],
191
+ iconFolder: "gfx/interface/icons/dynasty",
192
+ },
193
+ // _dynasty_perks.info, "Generated loc keys: key + "_name"" — and only that:
194
+ // all 105 vanilla perks define $_name, none defines $_desc (measured
195
+ // 2026-09-03 over localization/english). No iconFolder: gfx/interface/icons
196
+ // has a dynasty folder for the tracks and nothing for the perks.
197
+ { path: "common/dynasty_perks", kind: "dynasty_perk", locPatterns: ["$_name"] },
130
198
 
131
199
  // --- Activities & schemes ---
132
200
  { path: "common/activities/activity_types", kind: "activity_type", rootScopes: ["character"] },
@@ -190,7 +258,15 @@ const CK3_SCHEMA_BASE: SchemaEntry[] = [
190
258
  // remaining common/ folder with a standard top-level `name = { ... }`
191
259
  // layout, verified by parsing vanilla files. Ordered by definition count.
192
260
  { path: "common/game_concepts", kind: "game_concept", requiredLoc: ["game_concept_$"] },
193
- { path: "common/domiciles/buildings", kind: "domicile_building", rootScopes: ["character", "domicile"] },
261
+ {
262
+ path: "common/domiciles/buildings",
263
+ kind: "domicile_building",
264
+ rootScopes: ["character", "domicile"],
265
+ // Measured 2026-09-04: all 1620 vanilla domicile buildings define
266
+ // `$_domicile_building` (109 also `$_domicile_building_desc`), none a bare `$`.
267
+ requiredLoc: ["$_domicile_building"],
268
+ locPatterns: ["$_domicile_building", "$_domicile_building_desc"],
269
+ },
194
270
  { path: "common/domiciles/types", kind: "domicile_type", rootScopes: ["character"] },
195
271
  { path: "common/artifacts/features", kind: "artifact_feature" },
196
272
  { path: "common/artifacts/feature_groups", kind: "artifact_feature_group" },
@@ -824,6 +824,43 @@ const CURATED: Record<string, StructureSpec> = {
824
824
  const KEY_PATCHES: Record<string, Record<string, Partial<KeySpec> & { doc?: string }>> = {
825
825
  trait: {
826
826
  valid_sex: { values: "enum:all|male|female", doc: "Which sex can have the trait. Default: all." },
827
+ // `opposites = { chaste }` in game/common/traits/00_traits.txt: the entries
828
+ // are trait names. Deliberately NOT a global REF_FIELD - scripted_relations
829
+ // spells the same key over relation names (00_scripted_relations.txt,
830
+ // `friend = { opposites = { rival ... } }`), and a global row would
831
+ // mis-resolve there.
832
+ opposites: { refKinds: ["trait"] },
833
+ },
834
+ culture: {
835
+ // game/common/culture/cultures/00_arabic.txt: `traditions = {
836
+ // tradition_tribe_unity ... }` names definitions of
837
+ // common/culture/traditions, and `parents = { bedouin assyrian }` names
838
+ // other cultures (bedouin is the top-level key of the same file).
839
+ traditions: { refKinds: ["culture_tradition"] },
840
+ parents: { refKinds: ["culture"] },
841
+ // The five pillar keys all name definitions of common/culture/pillars
842
+ // (00_arabic.txt: `ethos = ethos_stoic heritage = heritage_arabic
843
+ // language = language_arabic martial_custom = martial_custom_male_only
844
+ // head_determination = head_determination_domain`, each of them a
845
+ // top-level key in common/culture/pillars/00_*.txt). One folder, so one
846
+ // ref kind; the pillar's own `type` sorts the five pickers apart (the
847
+ // schema entry's groupKey).
848
+ ethos: { refKinds: ["culture_pillar"] },
849
+ heritage: { refKinds: ["culture_pillar"] },
850
+ language: { refKinds: ["culture_pillar"] },
851
+ martial_custom: { refKinds: ["culture_pillar"] },
852
+ head_determination: { refKinds: ["culture_pillar"] },
853
+ // `name_list = name_list_bedouin` (00_arabic.txt) names a top-level key of
854
+ // common/culture/name_lists (00_arabic.txt line 1 there).
855
+ name_list: { refKinds: ["name_list"] },
856
+ },
857
+ dynasty_perk: {
858
+ // _dynasty_perks.info spells the block `traits = { trait_name = int }`, and
859
+ // game/common/dynasty_perks/00_dynasty_perks.txt writes it that way
860
+ // (blood_legacy_4: `beauty_good_1 = 100`, `fecund = 50`), so the entry keys
861
+ // are trait names and the numbers are AI chances. Per-KIND and not a global
862
+ // RefField: `traits` elsewhere is a history list, a filter, a count.
863
+ traits: { refKinds: ["trait"] },
827
864
  },
828
865
  scheme_type: {
829
866
  category: { values: "enum:personal|contract|hostile" },
@@ -8,9 +8,11 @@
8
8
  * upstream, commit and license) and has NOT been checked against a live EU5
9
9
  * install. Folder→kind mappings are only as right as those rules are; report
10
10
  * gaps with the "Schema gap" issue form, and work around them locally with the
11
- * `<mod>/.eu5modding/schema.json` overlay.
11
+ * `<mod>/.px-toolkit/schema.json` overlay.
12
12
  */
13
13
  import type { GameProfile } from "../profile";
14
+ import type { SkeletonData } from "../../schema/skeletons";
15
+ import SKELETONS from "../../../data/eu5/skeletons.json";
14
16
  import type { RefField, SchemaEntry } from "../../schema/types";
15
17
  import { JOMINI_VARIABLE_BLOCK_REFS } from "../jomini/variables";
16
18
  import { eu5Meta } from "./meta";
@@ -68,6 +70,10 @@ export const eu5Profile: GameProfile = {
68
70
  blockRefFields: { ...JOMINI_VARIABLE_BLOCK_REFS },
69
71
  // No `_*.info` docs ship with EU5, so the structures layer has no source.
70
72
  structureSources: {},
73
+ // Nobody here owns EU5, so its vanilla tree has never been measured and
74
+ // data/eu5/skeletons.json is empty. Wired anyway: a harvest run on a machine
75
+ // that has the game turns skeletons on with no code change.
76
+ skeletons: (SKELETONS as unknown as SkeletonData).kinds,
71
77
  modifierPlaceholders: {},
72
78
  // No bundled wiki tokens in the preview cut, so nothing ever renders this.
73
79
  wikiNote: "",
@@ -16,12 +16,16 @@ export const eu5Meta: GameMeta = {
16
16
  // EU5 mods carry .metadata/metadata.json (plus a required thumbnail.png);
17
17
  // descriptor.mod is at most a vestigial launcher artifact.
18
18
  descriptor: "metadata",
19
- configDirName: ".eu5modding",
19
+ configDirName: ".px-toolkit",
20
+ legacyConfigDirName: ".eu5modding",
20
21
  docsFolderName: "Europa Universalis V",
21
22
  // EU5's `script_docs` console command writes to Documents/.../docs, not logs/.
22
23
  scriptDocsSubdir: "docs",
23
24
  dataTypesCommand: "dump_data_types",
24
25
  steamAppId: 3450310,
26
+ // dlcIconDir deliberately absent: no live install has been checked, so the
27
+ // Workshop panel shows this game's DLC (read from `<gameDir>/dlc/`) with the
28
+ // folder's own thumbnail.png, and falls back to Steam when there is none.
25
29
  eventNamespaces: true,
26
30
  scaffolds: EU5_SCAFFOLDS,
27
31
  // uiFont and guiTextMetrics deliberately absent: neither the font file nor