hoi4-agent-tools 2.8.5 → 2.8.6

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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/hoi4_agent_tools/core/artifacts.js +14 -6
  3. package/dist/hoi4_agent_tools/gui/assets.js +0 -5
  4. package/dist/hoi4_agent_tools/gui/layout.js +4 -1
  5. package/dist/hoi4_agent_tools/gui/renderer.js +12 -1
  6. package/dist/hoi4_agent_tools/gui/scenario-generator.js +402 -69
  7. package/dist/hoi4_agent_tools/gui/scenario.js +1 -1
  8. package/dist/hoi4_agent_tools/gui/source-graph.js +61 -9
  9. package/dist/hoi4_agent_tools/gui/studio.js +45 -4
  10. package/dist/hoi4_agent_tools/gui/validators.js +6 -1
  11. package/dist/hoi4_agent_tools/version.js +1 -1
  12. package/docs/gui.md +4 -4
  13. package/package.json +1 -1
  14. package/schemas/configuration.schema.json +1 -1
  15. package/schemas/continuous-focus-palette.schema.json +1 -1
  16. package/schemas/custom-weighted-pool.schema.json +1 -1
  17. package/schemas/event-feature-manifest.schema.json +1 -1
  18. package/schemas/focus-plan.schema.json +1 -1
  19. package/schemas/focus-planning-sidecar.schema.json +1 -1
  20. package/schemas/gui-animation-source.schema.json +1 -1
  21. package/schemas/gui-helper.schema.json +1 -1
  22. package/schemas/gui-scenario.schema.json +1 -1
  23. package/schemas/map-operation.schema.json +1 -1
  24. package/schemas/operation-result.schema.json +1 -1
  25. package/schemas/probability-analysis-result.schema.json +1 -1
  26. package/schemas/probability-compare-input.schema.json +1 -1
  27. package/schemas/probability-evaluate-input.schema.json +1 -1
  28. package/schemas/probability-inspect-input.schema.json +1 -1
  29. package/schemas/probability-render-input.schema.json +1 -1
  30. package/schemas/probability-scenario-set.schema.json +1 -1
  31. package/schemas/probability-sequence-input.schema.json +1 -1
  32. package/schemas/probability-simulate-input.schema.json +1 -1
  33. package/schemas/probability-sweep-input.schema.json +1 -1
  34. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 2.8.6 - 2026-08-28
8
+
9
+ - Generate source-aware scripted-GUI scenarios from complete parent/child surfaces, real scripted-localisation candidates, simple threshold conditions, coherent tab states, scoped values, meter ranges, and dynamic-list rows while keeping explicit scenario values authoritative.
10
+ - Preserve native HOI4 bitmap-font antialiasing and shadow pixels when applying face and localisation colours, including global and per-font `textcolors` palettes.
11
+ - Prevent stale workspace artifacts and irrelevant language font atlases from blocking GUI calls, resolve alternate vanilla raster extensions and repeated path separators, and keep placeholder scenarios opt-in so their intentionally unresolved state does not pollute normal validation.
12
+
7
13
  ## 2.8.5 - 2026-08-28
8
14
 
9
15
  - Render bitmap-font face and localisation colours through alpha masks, including black source atlases, and load exact per-font HOI4 `textcolors` palettes.
@@ -131,9 +131,14 @@ export function boundedSourceHashEvidence(sourceHashes) {
131
131
  };
132
132
  }
133
133
  function assertManifestIntegrity(manifest, expected, workspace) {
134
+ assertManifestAddressIntegrity(manifest, expected);
134
135
  if (manifest.workspaceIdentity !== workspace.workspaceIdentity ||
135
- manifest.ownerIdentity !== workspace.ownerIdentity ||
136
- manifest.sha256 !== expected.sha256 ||
136
+ manifest.ownerIdentity !== workspace.ownerIdentity) {
137
+ throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Artifact provenance manifest does not match its immutable address');
138
+ }
139
+ }
140
+ function assertManifestAddressIntegrity(manifest, expected) {
141
+ if (manifest.sha256 !== expected.sha256 ||
137
142
  manifest.provenanceHash !== expected.provenanceHash ||
138
143
  manifest.name !== expected.name ||
139
144
  provenanceHashFor(manifest) !== expected.provenanceHash) {
@@ -712,11 +717,11 @@ export class ArtifactStore {
712
717
  if (child.name !== `${manifest.name}.${manifest.provenanceHash}.manifest.json`) {
713
718
  throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Artifact provenance manifest filename does not match its contents');
714
719
  }
715
- assertManifestIntegrity(manifest, {
720
+ assertManifestAddressIntegrity(manifest, {
716
721
  sha256: path.basename(directory),
717
722
  provenanceHash: manifest.provenanceHash,
718
723
  name: manifest.name,
719
- }, workspace);
724
+ });
720
725
  const targetPath = await containedGeneratedPath(workspace.artifactRoot, path.relative(workspace.artifactRoot, path.join(directory, manifest.name)));
721
726
  let targetBytes = 0;
722
727
  try {
@@ -728,6 +733,8 @@ export class ArtifactStore {
728
733
  }
729
734
  retained.push({
730
735
  manifest,
736
+ staleIdentity: manifest.workspaceIdentity !== workspace.workspaceIdentity ||
737
+ manifest.ownerIdentity !== workspace.ownerIdentity,
731
738
  manifestPath: candidate,
732
739
  manifestBytes: metadata.size,
733
740
  modifiedAt: metadata.mtimeMs,
@@ -789,7 +796,8 @@ export class ArtifactStore {
789
796
  }
790
797
  };
791
798
  await reclaimDebris(await containedGeneratedPath(workspace.artifactRoot));
792
- retained.sort((left, right) => left.modifiedAt - right.modifiedAt ||
799
+ retained.sort((left, right) => Number(right.staleIdentity) - Number(left.staleIdentity) ||
800
+ left.modifiedAt - right.modifiedAt ||
793
801
  compareCodeUnits(left.manifestPath, right.manifestPath));
794
802
  const current = { ...usage };
795
803
  for (const candidate of retained) {
@@ -822,7 +830,7 @@ export class ArtifactStore {
822
830
  if (error.code !== 'ENOENT')
823
831
  throw error;
824
832
  }
825
- const verificationPrefix = `${workspace.workspaceIdentity}\0${workspace.ownerIdentity}\0${candidate.targetPath}\0`;
833
+ const verificationPrefix = `${candidate.manifest.workspaceIdentity}\0${candidate.manifest.ownerIdentity}\0${candidate.targetPath}\0`;
826
834
  for (const key of this.#verifiedContent.keys()) {
827
835
  if (key.startsWith(verificationPrefix))
828
836
  this.#verifiedContent.delete(key);
@@ -591,11 +591,6 @@ export class GuiAssetCatalog {
591
591
  .ensureAlpha()
592
592
  .raw()
593
593
  .toBuffer({ resolveWithObject: true });
594
- for (let index = 0; index < extracted.data.length; index += 4) {
595
- extracted.data[index] = 255;
596
- extracted.data[index + 1] = 255;
597
- extracted.data[index + 2] = 255;
598
- }
599
594
  const png = await sharp(extracted.data, {
600
595
  raw: { width: extracted.info.width, height: extracted.info.height, channels: 4 },
601
596
  limitInputPixels: RENDER_MAX_DECODED_PIXELS,
@@ -750,7 +750,10 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
750
750
  displayText = `\u00a7R${rawText}_MISSING\u00a7!`;
751
751
  const fontName = scalarString(property(definition.attributes, 'font', 'buttonFont'));
752
752
  const fontDefinition = fontName === undefined ? undefined : catalog.fontDefinition(fontName);
753
- const visibleText = visibleHoiText(displayText, fontDefinition?.textColours);
753
+ const visibleText = visibleHoiText(displayText, {
754
+ ...context.graph.textColours,
755
+ ...fontDefinition?.textColours,
756
+ });
754
757
  displayText = visibleText.text;
755
758
  context.work.admitText(displayText, `GUI text for ${definition.name}`);
756
759
  const resolvedFontMetrics = catalog.resolvedFontMetrics(fontName);
@@ -63,7 +63,18 @@ function sceneGlyphDefinitions(scene) {
63
63
  ].join('');
64
64
  }
65
65
  function bitmapTintMarkup(id, colour, glyphs, rect) {
66
- return `<defs><mask id="${id}" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" style="mask-type:alpha" ${rectAttributes(rect)}><g>${glyphs}</g></mask></defs><rect ${rectAttributes(rect)} fill="${colour}" mask="url(#${id})"/>`;
66
+ const match = /^#([0-9a-f]{6})([0-9a-f]{2})?$/iu.exec(colour);
67
+ const rgb = match?.[1] ?? 'ffffff';
68
+ const alpha = match?.[2] ?? 'ff';
69
+ const channels = [
70
+ Number.parseInt(rgb.slice(0, 2), 16) / 255,
71
+ Number.parseInt(rgb.slice(2, 4), 16) / 255,
72
+ Number.parseInt(rgb.slice(4, 6), 16) / 255,
73
+ Number.parseInt(alpha, 16) / 255,
74
+ ].map(finite);
75
+ const [red, green, blue, opacity] = channels;
76
+ const matrix = `${red} 0 0 0 0 0 ${green} 0 0 0 0 0 ${blue} 0 0 0 0 0 ${opacity} 0`;
77
+ return `<defs><filter id="${id}" filterUnits="userSpaceOnUse" ${rectAttributes(rect)} color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${matrix}"/></filter></defs><g filter="url(#${id})">${glyphs}</g>`;
67
78
  }
68
79
  function actualGlyphMarkup(glyphLine, originX, baseline, horizontalScale) {
69
80
  if (glyphLine.source === 'fontkit-path')
@@ -11,6 +11,20 @@ const countryAdjectives = [
11
11
  'Canadian',
12
12
  ];
13
13
  const leaderNames = ['Anna Keller', 'Michael Byrne', 'Elena Rossi', 'Jan Kowalski', 'Rafael Silva'];
14
+ const monthNames = [
15
+ 'January',
16
+ 'February',
17
+ 'March',
18
+ 'April',
19
+ 'May',
20
+ 'June',
21
+ 'July',
22
+ 'August',
23
+ 'September',
24
+ 'October',
25
+ 'November',
26
+ 'December',
27
+ ];
14
28
  const variedStates = [
15
29
  'normal',
16
30
  'selected',
@@ -19,6 +33,7 @@ const variedStates = [
19
33
  'disabled',
20
34
  'completed',
21
35
  ];
36
+ const transientWindowPattern = /(?:confirm|details|dialog|dropdown|menu|modal|overlay|popup|tooltip)/iu;
22
37
  class ScenarioRandom {
23
38
  #state;
24
39
  constructor(seed) {
@@ -41,26 +56,50 @@ class ScenarioRandom {
41
56
  return values[Math.min(values.length - 1, Math.floor(this.next() * values.length))];
42
57
  }
43
58
  }
44
- function relevantElements(graph, windowName) {
45
- const root = graph.elements.find((element) => element.parentId === undefined && element.name === windowName);
46
- if (root === undefined)
47
- return graph.elements.filter(({ name }) => name === windowName);
59
+ function relatedScriptedGuis(graph, windowName) {
60
+ const names = new Set(graph.scriptedGuis
61
+ .filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)
62
+ .map(({ name }) => name));
63
+ let added = true;
64
+ while (added) {
65
+ added = false;
66
+ for (const definition of graph.scriptedGuis) {
67
+ if (definition.parentScriptedGui === undefined ||
68
+ !names.has(definition.parentScriptedGui) ||
69
+ names.has(definition.name))
70
+ continue;
71
+ names.add(definition.name);
72
+ added = true;
73
+ }
74
+ }
75
+ return graph.scriptedGuis.filter(({ name }) => names.has(name));
76
+ }
77
+ function relevantElements(graph, windowName, scripted) {
78
+ const rootNames = new Set([windowName]);
79
+ for (const definition of scripted) {
80
+ if (definition.windowName !== undefined)
81
+ rootNames.add(definition.windowName);
82
+ for (const dynamicList of definition.dynamicListDefinitions) {
83
+ if (dynamicList.entryContainer !== undefined)
84
+ rootNames.add(dynamicList.entryContainer);
85
+ if (dynamicList.countryScopeEntryContainer !== undefined)
86
+ rootNames.add(dynamicList.countryScopeEntryContainer);
87
+ }
88
+ }
48
89
  const byId = new Map(graph.elements.map((element) => [element.id, element]));
49
- const pending = [root.id];
50
- const selected = [];
51
- const seen = new Set();
90
+ const pending = graph.elements.filter(({ name }) => rootNames.has(name)).map(({ id }) => id);
91
+ const selected = new Map();
52
92
  while (pending.length > 0) {
53
93
  const id = pending.pop();
54
- if (seen.has(id))
94
+ if (id === undefined || selected.has(id))
55
95
  continue;
56
- seen.add(id);
57
96
  const element = byId.get(id);
58
97
  if (element === undefined)
59
98
  continue;
60
- selected.push(element);
99
+ selected.set(id, element);
61
100
  pending.push(...element.childIds);
62
101
  }
63
- return selected;
102
+ return [...selected.values()];
64
103
  }
65
104
  function stringsIn(value) {
66
105
  if (typeof value === 'string')
@@ -70,18 +109,37 @@ function stringsIn(value) {
70
109
  return Object.values(value).flatMap(stringsIn);
71
110
  }
72
111
  function localisationText(graph, language, key) {
73
- return graph.localisation.find((entry) => entry.language === language && entry.key === key)
74
- ?.value;
112
+ const normalizedLanguage = language.toLocaleLowerCase('en-US');
113
+ return graph.localisation.find((entry) => entry.key === key && entry.language.toLocaleLowerCase('en-US') === normalizedLanguage)?.value;
75
114
  }
76
115
  function sourceTexts(graph, elements, language, scripted) {
77
116
  const values = elements.flatMap((element) => Object.values(element.attributes).flatMap(stringsIn));
78
- const propertyValues = scripted.flatMap((definition) => definition.propertyDefinitions.flatMap((property) => Object.values(property.attributes).flatMap(stringsIn)));
79
- return [...values, ...propertyValues].flatMap((value) => [
117
+ const resolvedValues = values.flatMap((value) => [
80
118
  value,
81
119
  localisationText(graph, language, value) ?? '',
82
120
  ]);
121
+ const relatedScriptedLocalisationKeys = new Set(graph.scriptedLocalisation
122
+ .filter((definition) => resolvedValues.some((value) => value.includes(`[${definition.name}]`)))
123
+ .flatMap(({ localisationKeys }) => localisationKeys));
124
+ const propertyValues = scripted.flatMap(({ propertyDefinitions }) => propertyDefinitions.flatMap(({ attributes }) => Object.values(attributes).flatMap(stringsIn)));
125
+ const normalizedLanguage = language.toLocaleLowerCase('en-US');
126
+ return [
127
+ ...resolvedValues,
128
+ ...propertyValues,
129
+ ...graph.localisation
130
+ .filter(({ language: entryLanguage, key }) => entryLanguage.toLocaleLowerCase('en-US') === normalizedLanguage &&
131
+ relatedScriptedLocalisationKeys.has(key))
132
+ .map(({ value }) => value),
133
+ ];
134
+ }
135
+ function expressionToken(value) {
136
+ if (typeof value !== 'string')
137
+ return undefined;
138
+ const variable = /^\[\?([^\]|]+)(?:\|[^\]]+)?\]$/u.exec(value);
139
+ const dynamic = /^\[([^\]]+)\]$/u.exec(value);
140
+ return variable?.[1] ?? dynamic?.[1];
83
141
  }
84
- function dynamicTokens(texts) {
142
+ function tokensIn(texts) {
85
143
  const numeric = new Set();
86
144
  const textual = new Set();
87
145
  for (const text of texts) {
@@ -98,33 +156,248 @@ function dynamicTokens(texts) {
98
156
  textual.add(key);
99
157
  }
100
158
  }
101
- return {
102
- numeric: [...numeric].sort(),
103
- textual: [...textual].sort(),
104
- };
159
+ return { numeric, textual };
105
160
  }
106
- function plausibleText(key, random, samples) {
161
+ function plausibleText(key, random, samples, scopedCountries) {
107
162
  const lower = key.toLocaleLowerCase('en-US');
163
+ if (lower.includes('date'))
164
+ return `${random.integer(1, 28)} ${random.pick(monthNames)} ${random.integer(1936, 1950)}`;
108
165
  if (lower.includes('adjective'))
109
166
  return random.pick(countryAdjectives);
110
167
  if (lower.includes('leader') || lower.includes('character'))
111
168
  return random.pick(leaderNames);
112
- if (lower.includes('country') || lower.endsWith('getname') || lower.includes('.getname'))
113
- return random.pick(countryNames);
169
+ if (lower.includes('country') || lower.endsWith('getname') || lower.includes('.getname')) {
170
+ const scope = key.includes('.') ? key.slice(0, key.indexOf('.')) : 'ROOT';
171
+ const existing = scopedCountries.get(scope);
172
+ if (existing !== undefined)
173
+ return existing;
174
+ const used = new Set(scopedCountries.values());
175
+ const choices = countryNames.filter((candidate) => !used.has(candidate));
176
+ const value = random.pick(choices.length === 0 ? countryNames : choices);
177
+ scopedCountries.set(scope, value);
178
+ return value;
179
+ }
114
180
  return random.pick(samples);
115
181
  }
116
- function generatedNumber(options, random) {
117
- const value = options.numericMinimum + random.next() * (options.numericMaximum - options.numericMinimum);
182
+ function generatedNumber(options, random, minimum = options.numericMinimum, maximum = options.numericMaximum) {
183
+ const boundedMinimum = Math.max(options.numericMinimum, minimum);
184
+ const boundedMaximum = Math.min(options.numericMaximum, maximum);
185
+ const low = Math.min(boundedMinimum, boundedMaximum);
186
+ const high = Math.max(boundedMinimum, boundedMaximum);
187
+ const value = low + random.next() * (high - low);
118
188
  return options.integerValues ? Math.round(value) : Math.round(value * 100) / 100;
119
189
  }
190
+ function generatedNumberForKey(key, options, random) {
191
+ const lower = key.toLocaleLowerCase('en-US');
192
+ if (/(?:ratio|fraction|progress)$/u.test(lower))
193
+ return generatedNumber(options, random, 0, 1);
194
+ if (/(?:percent|percentage|support|stability|war_support|legitimacy|threat|charge)/u.test(lower))
195
+ return generatedNumber(options, random, 0, 100);
196
+ return generatedNumber(options, random);
197
+ }
120
198
  function setIfMissing(target, key, value) {
121
199
  target[key] ??= value;
122
- const shortKey = key.slice(key.lastIndexOf('.') + 1);
123
- target[shortKey] ??= value;
200
+ }
201
+ function scriptedLocalisationValue(graph, token, language, random, kind, values) {
202
+ const shortToken = token.slice(token.lastIndexOf('.') + 1);
203
+ const definitions = graph.scriptedLocalisation.filter(({ name }) => name === token || name === shortToken);
204
+ if (definitions.length === 0)
205
+ return undefined;
206
+ const sprites = new Set(graph.sprites.map(({ name }) => name.toLocaleLowerCase('en-US')));
207
+ const choices = definitions.flatMap(({ choices }) => choices);
208
+ const eligible = choices.filter(({ localisationKey }) => kind === 'image'
209
+ ? sprites.has(localisationKey.toLocaleLowerCase('en-US'))
210
+ : localisationText(graph, language, localisationKey) !== undefined);
211
+ if (eligible.length === 0)
212
+ return undefined;
213
+ const evaluated = eligible.map((choice) => ({
214
+ choice,
215
+ result: evaluateSimpleTrigger(choice.triggerExpression, values),
216
+ }));
217
+ const matched = evaluated.find(({ result }) => result === true)?.choice;
218
+ const firstThreshold = evaluated
219
+ .map(({ choice }) => simpleThreshold(choice.triggerExpression))
220
+ .find((threshold) => threshold !== undefined && typeof values[threshold.variable] === 'number');
221
+ const unknownExists = evaluated.some(({ result }) => result === undefined);
222
+ const ranked = matched === undefined && unknownExists && firstThreshold !== undefined
223
+ ? eligible[Math.min(eligible.length - 1, Math.max(0, Math.round((1 - Math.max(0, Math.min(100, Number(values[firstThreshold.variable]))) / 100) *
224
+ (eligible.length - 1))))]
225
+ : undefined;
226
+ const fallback = evaluated.find(({ result }) => result === 'fallback')?.choice;
227
+ const selected = matched ?? ranked ?? fallback ?? random.pick(eligible);
228
+ if (kind === 'image')
229
+ return selected.localisationKey;
230
+ return localisationText(graph, language, selected.localisationKey);
231
+ }
232
+ function simpleThreshold(triggerExpression) {
233
+ if (triggerExpression === undefined)
234
+ return undefined;
235
+ const direct = /([A-Za-z_][A-Za-z0-9_.:^]*)\s*([<>])\s*(-?(?:\d+\.?\d*|\.\d+))/u.exec(triggerExpression);
236
+ if (direct !== null)
237
+ return {
238
+ variable: direct[1],
239
+ operator: direct[2],
240
+ threshold: Number(direct[3]),
241
+ };
242
+ const variable = /\bvar\s*=\s*([A-Za-z_][A-Za-z0-9_.:^]*)/u.exec(triggerExpression)?.[1];
243
+ const threshold = /\bvalue\s*=\s*(-?(?:\d+\.?\d*|\.\d+))/u.exec(triggerExpression)?.[1];
244
+ const compare = /\bcompare\s*=\s*(greater_than|less_than)/u.exec(triggerExpression)?.[1];
245
+ if (variable === undefined || threshold === undefined || compare === undefined)
246
+ return undefined;
247
+ return {
248
+ variable,
249
+ operator: compare === 'greater_than' ? '>' : '<',
250
+ threshold: Number(threshold),
251
+ };
252
+ }
253
+ function evaluateSimpleTrigger(triggerExpression, values) {
254
+ if (triggerExpression === undefined || /\balways\s*=\s*yes\b/u.test(triggerExpression))
255
+ return 'fallback';
256
+ if (/\balways\s*=\s*no\b/u.test(triggerExpression))
257
+ return false;
258
+ const threshold = simpleThreshold(triggerExpression);
259
+ if (threshold === undefined)
260
+ return undefined;
261
+ const value = values[threshold.variable];
262
+ if (typeof value !== 'number')
263
+ return undefined;
264
+ return threshold.operator === '>' ? value > threshold.threshold : value < threshold.threshold;
265
+ }
266
+ function materializeDynamicText(template, values) {
267
+ const valueFor = (key) => {
268
+ const value = values[key] ?? values[key.slice(key.lastIndexOf('.') + 1)];
269
+ return value === undefined ? undefined : String(value);
270
+ };
271
+ return template
272
+ .replace(/\[\?([^\]|]+)(?:\|[^\]]+)?\]/gu, (match, key) => valueFor(key) ?? match)
273
+ .replace(/\[([A-Za-z][A-Za-z0-9_.:-]+)\]/gu, (match, key) => {
274
+ if (key === 'X' || key === 'dynamic_loc')
275
+ return match;
276
+ return valueFor(key) ?? match;
277
+ });
278
+ }
279
+ function scalarNumber(value) {
280
+ if (typeof value === 'number' && Number.isFinite(value))
281
+ return value;
282
+ if (typeof value !== 'string' || !/^-?(?:\d+\.?\d*|\.\d+)$/u.test(value))
283
+ return undefined;
284
+ return Number(value);
285
+ }
286
+ function pointOrSize(element, attribute) {
287
+ const value = element.attributes[attribute];
288
+ if (typeof value !== 'object' || Array.isArray(value))
289
+ return undefined;
290
+ const x = scalarNumber(value.x ?? value.width);
291
+ const y = scalarNumber(value.y ?? value.height);
292
+ return x === undefined || y === undefined ? undefined : { x, y };
293
+ }
294
+ function rootRect(graph, windowName) {
295
+ if (windowName === undefined)
296
+ return undefined;
297
+ const root = graph.elements.find((element) => element.name === windowName && element.parentId === undefined);
298
+ if (root === undefined)
299
+ return undefined;
300
+ const position = pointOrSize(root, 'position') ?? { x: 0, y: 0 };
301
+ const size = pointOrSize(root, 'size');
302
+ if (size === undefined || size.x <= 0 || size.y <= 0)
303
+ return undefined;
304
+ return { x: position.x, y: position.y, width: size.x, height: size.y };
305
+ }
306
+ function substantiallyOverlaps(left, right) {
307
+ if (left === undefined || right === undefined)
308
+ return false;
309
+ const width = Math.max(0, Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x));
310
+ const height = Math.max(0, Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y));
311
+ const smallerArea = Math.min(left.width * left.height, right.width * right.height);
312
+ return smallerArea > 0 && (width * height) / smallerArea >= 0.5;
313
+ }
314
+ function generatedVisibility(graph, scripted, options, random, base) {
315
+ const visibility = { ...base };
316
+ const dynamic = scripted.filter(({ visibleExpression }) => visibleExpression !== undefined);
317
+ if (options.visibility === 'show-all') {
318
+ for (const definition of dynamic)
319
+ visibility[definition.name] ??= true;
320
+ return visibility;
321
+ }
322
+ for (const definition of dynamic) {
323
+ if (visibility[definition.name] !== undefined)
324
+ continue;
325
+ visibility[definition.name] =
326
+ definition.parentScriptedGui === undefined ||
327
+ !transientWindowPattern.test(`${definition.name} ${definition.windowName ?? ''}`);
328
+ }
329
+ const siblingGroups = new Map();
330
+ for (const definition of dynamic) {
331
+ if (definition.parentScriptedGui === undefined)
332
+ continue;
333
+ const siblings = siblingGroups.get(definition.parentScriptedGui) ?? [];
334
+ siblings.push(definition);
335
+ siblingGroups.set(definition.parentScriptedGui, siblings);
336
+ }
337
+ for (const siblings of siblingGroups.values()) {
338
+ const unvisited = new Set(siblings);
339
+ while (unvisited.size > 0) {
340
+ const first = unvisited.values().next().value;
341
+ if (first === undefined)
342
+ break;
343
+ unvisited.delete(first);
344
+ const component = [first];
345
+ for (const current of component) {
346
+ for (const candidate of [...unvisited]) {
347
+ if (substantiallyOverlaps(rootRect(graph, current.windowName), rootRect(graph, candidate.windowName))) {
348
+ component.push(candidate);
349
+ unvisited.delete(candidate);
350
+ }
351
+ }
352
+ }
353
+ if (component.length < 2)
354
+ continue;
355
+ const selectable = component.filter((definition) => !transientWindowPattern.test(`${definition.name} ${definition.windowName ?? ''}`));
356
+ const selected = random.pick(selectable.length === 0 ? component : selectable);
357
+ for (const definition of component) {
358
+ if (base[definition.name] === undefined)
359
+ visibility[definition.name] = definition === selected;
360
+ }
361
+ }
362
+ }
363
+ const visiblePageNames = dynamic
364
+ .filter(({ name }) => visibility[name] === true)
365
+ .flatMap(({ name, windowName: visibleWindowName }) => [name, visibleWindowName ?? ''])
366
+ .map((name) => name.toLocaleLowerCase('en-US'));
367
+ const triggerVisibilityTargets = [
368
+ ...new Set(scripted.flatMap(({ triggerDefinitions }) => triggerDefinitions
369
+ .filter(({ name }) => name.endsWith('_visible'))
370
+ .map(({ elementName }) => elementName))),
371
+ ];
372
+ const triggerGroups = new Map();
373
+ for (const target of triggerVisibilityTargets) {
374
+ const key = target.replace(/_(?:active|disabled|enabled|idle|off|on|selected|unselected)$/iu, '');
375
+ const group = triggerGroups.get(key) ?? [];
376
+ group.push(target);
377
+ triggerGroups.set(key, group);
378
+ }
379
+ for (const [baseName, targets] of triggerGroups) {
380
+ if (targets.length === 1) {
381
+ visibility[targets[0]] ??= !transientWindowPattern.test(targets[0]);
382
+ continue;
383
+ }
384
+ const topic = baseName.replace(/_(?:tab_)?button$/iu, '').toLocaleLowerCase('en-US');
385
+ const pageSelected = visiblePageNames.some((name) => name.includes(topic));
386
+ const preferredSuffix = pageSelected
387
+ ? /_(?:active|on|selected)$/iu
388
+ : /_(?:idle|off|unselected)$/iu;
389
+ const preferred = targets.find((target) => preferredSuffix.test(target)) ?? random.pick(targets);
390
+ for (const target of targets)
391
+ if (base[target] === undefined)
392
+ visibility[target] = target === preferred;
393
+ }
394
+ return visibility;
124
395
  }
125
396
  function generatedRows(count, random, options) {
397
+ const countryOffset = random.integer(0, countryNames.length - 1);
126
398
  return Array.from({ length: count }, (_unused, index) => {
127
- const name = plausibleText('GetName', random, options.textSamples);
399
+ const countryIndex = (countryOffset + index) % countryNames.length;
400
+ const name = countryNames[countryIndex];
128
401
  const value = generatedNumber(options, random);
129
402
  return {
130
403
  index: index + 1,
@@ -132,70 +405,131 @@ function generatedRows(count, random, options) {
132
405
  label: name,
133
406
  name,
134
407
  GetName: name,
408
+ GetAdjective: countryAdjectives[countryIndex],
135
409
  value,
136
410
  amount: value,
137
411
  percentage: Math.max(0, Math.min(100, value)),
138
412
  selected: index === 0,
139
- enabled: random.boolean(options.trueProbability),
413
+ enabled: true,
140
414
  };
141
415
  });
142
416
  }
143
417
  export function generateGuiPreviewScenarios(graph, windowName, base, options) {
144
418
  if (!options.enabled)
145
419
  return [];
146
- const elements = relevantElements(graph, windowName);
147
- const scripted = graph.scriptedGuis.filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName);
148
- const tokens = dynamicTokens(sourceTexts(graph, elements, base.language, scripted));
420
+ const scripted = relatedScriptedGuis(graph, windowName);
421
+ const elements = relevantElements(graph, windowName, scripted);
422
+ const tokens = tokensIn(sourceTexts(graph, elements, base.language, scripted));
423
+ const imageTokens = new Set();
424
+ const booleanTokens = new Set();
425
+ const frameTokens = new Set();
426
+ for (const definition of scripted) {
427
+ for (const property of definition.propertyDefinitions) {
428
+ for (const [attribute, expression] of Object.entries(property.attributes)) {
429
+ const token = expressionToken(expression);
430
+ if (token === undefined)
431
+ continue;
432
+ const normalized = attribute.toLocaleLowerCase('en-US');
433
+ if (normalized === 'image')
434
+ imageTokens.add(token);
435
+ else if (normalized === 'visible' || normalized === 'enabled')
436
+ booleanTokens.add(token);
437
+ else if (normalized === 'frame')
438
+ frameTokens.add(token);
439
+ else
440
+ tokensIn(stringsIn(expression)).numeric.forEach((value) => tokens.numeric.add(value));
441
+ }
442
+ }
443
+ }
444
+ for (const token of [...imageTokens, ...booleanTokens, ...frameTokens]) {
445
+ tokens.numeric.delete(token);
446
+ tokens.textual.delete(token);
447
+ }
448
+ const relevantDynamicTokens = new Set([...tokens.textual, ...imageTokens]);
449
+ for (const definition of graph.scriptedLocalisation) {
450
+ if (![...relevantDynamicTokens].some((token) => token === definition.name || token.endsWith(`.${definition.name}`)))
451
+ continue;
452
+ for (const choice of definition.choices) {
453
+ const threshold = simpleThreshold(choice.triggerExpression);
454
+ if (threshold !== undefined)
455
+ tokens.numeric.add(threshold.variable);
456
+ }
457
+ }
458
+ const progressRanges = new Map();
459
+ for (const element of elements) {
460
+ if (!/progressbar/iu.test(element.elementType))
461
+ continue;
462
+ const minimum = scalarNumber(element.attributes.minValue) ?? 0;
463
+ const maximum = scalarNumber(element.attributes.maxValue) ?? 100;
464
+ progressRanges.set(element.name, { minimum, maximum });
465
+ const startValue = element.attributes.startValue;
466
+ if (typeof startValue === 'string' && !/^-?(?:\d+\.?\d*|\.\d+)$/u.test(startValue))
467
+ tokens.numeric.add(startValue);
468
+ }
149
469
  const dynamicLists = [...new Set(scripted.flatMap(({ dynamicLists }) => dynamicLists))].sort();
150
- const visibilityTargets = new Set(scripted.flatMap((definition) => definition.propertyDefinitions
151
- .filter((property) => Object.keys(property.attributes).some((key) => key.toLocaleLowerCase('en-US') === 'visible'))
152
- .map(({ elementName }) => elementName)));
153
470
  const enabledTargets = new Set(scripted.flatMap((definition) => [
154
471
  ...definition.triggerDefinitions.map(({ elementName }) => elementName),
155
472
  ...definition.propertyDefinitions
156
473
  .filter((property) => Object.keys(property.attributes).some((key) => key.toLocaleLowerCase('en-US') === 'enabled'))
157
474
  .map(({ elementName }) => elementName),
158
475
  ]));
159
- const spritesByName = new Map(graph.sprites.map((sprite) => [sprite.name.toLocaleLowerCase('en-US'), sprite]));
160
476
  const sourceSeed = options.seed === 'auto' ? `${windowName}:${base.id}:${hashSourceGraph(graph)}` : options.seed;
161
477
  return Array.from({ length: options.count }, (_unused, scenarioIndex) => {
162
478
  const random = new ScenarioRandom(`${sourceSeed}:${scenarioIndex}`);
163
479
  const values = { ...base.values };
164
480
  const lists = Object.fromEntries(Object.entries(base.lists).map(([key, rows]) => [key, rows.map((row) => ({ ...row }))]));
165
- const visibility = { ...base.visibility };
481
+ const visibility = generatedVisibility(graph, scripted, options, random, base.visibility);
166
482
  const elementStates = { ...base.elementStates };
167
- const selectedFrames = { ...base.selectedFrames };
483
+ const scopedCountries = new Map();
168
484
  for (const key of tokens.numeric)
169
- setIfMissing(values, key, generatedNumber(options, random));
485
+ setIfMissing(values, key, generatedNumberForKey(key, options, random));
486
+ for (const [elementName, { minimum, maximum }] of progressRanges) {
487
+ const element = elements.find(({ name }) => name === elementName);
488
+ const startValueToken = typeof element?.attributes.startValue === 'string'
489
+ ? element.attributes.startValue
490
+ : undefined;
491
+ const existingElementValue = values[elementName];
492
+ const existingTokenValue = startValueToken === undefined ? undefined : values[startValueToken];
493
+ const value = typeof existingElementValue === 'number'
494
+ ? existingElementValue
495
+ : typeof existingTokenValue === 'number'
496
+ ? existingTokenValue
497
+ : generatedNumber(options, random, minimum, maximum);
498
+ setIfMissing(values, elementName, value);
499
+ if (startValueToken !== undefined)
500
+ setIfMissing(values, startValueToken, value);
501
+ }
502
+ for (const key of tokens.numeric) {
503
+ if (!key.endsWith('_bar') || base.values[key] !== undefined)
504
+ continue;
505
+ const sourceKey = key.slice(0, -'_bar'.length);
506
+ const sourceValue = values[sourceKey];
507
+ if (typeof sourceValue === 'number')
508
+ values[key] = sourceValue;
509
+ }
170
510
  for (const key of tokens.textual)
171
- setIfMissing(values, key, plausibleText(key, random, options.textSamples));
172
- for (const element of elements) {
173
- if (values[element.name] === undefined && /progressbar/iu.test(element.elementType))
174
- values[element.name] = generatedNumber(options, random);
175
- if (visibilityTargets.has(element.name) && visibility[element.name] === undefined)
176
- visibility[element.name] =
177
- options.visibility === 'show-all' || random.boolean(options.trueProbability);
178
- elementStates[element.name] ??= enabledTargets.has(element.name)
179
- ? random.boolean(options.trueProbability)
180
- ? options.elementStates === 'normal'
181
- ? 'normal'
182
- : random.pick(variedStates)
183
- : 'disabled'
184
- : options.elementStates === 'normal'
185
- ? 'normal'
186
- : random.pick(variedStates);
187
- const spriteName = [
188
- element.attributes.spriteType,
189
- element.attributes.quadTextureSprite,
190
- element.attributes.background,
191
- ].find((candidate) => typeof candidate === 'string');
192
- const frameCount = spriteName === undefined
193
- ? 1
194
- : (spritesByName.get(spriteName.toLocaleLowerCase('en-US'))?.frameCount ?? 1);
195
- if (Number.isFinite(frameCount) &&
196
- frameCount > 1 &&
197
- selectedFrames[element.name] === undefined)
198
- selectedFrames[element.name] = random.integer(0, Math.trunc(frameCount) - 1);
511
+ setIfMissing(values, key, plausibleText(key, random, options.textSamples, scopedCountries));
512
+ const scriptedTextTemplates = new Map();
513
+ for (const key of tokens.textual) {
514
+ const scriptedValue = scriptedLocalisationValue(graph, key, base.language, random, 'text', values);
515
+ if (scriptedValue !== undefined && base.values[key] === undefined)
516
+ scriptedTextTemplates.set(key, scriptedValue);
517
+ }
518
+ for (let pass = 0; pass < 3; pass += 1)
519
+ for (const [key, template] of scriptedTextTemplates)
520
+ values[key] = materializeDynamicText(template, values);
521
+ for (const key of imageTokens) {
522
+ const sprite = scriptedLocalisationValue(graph, key, base.language, random, 'image', values);
523
+ if (sprite !== undefined)
524
+ setIfMissing(values, key, sprite);
525
+ }
526
+ for (const key of booleanTokens)
527
+ setIfMissing(values, key, random.boolean(options.trueProbability));
528
+ for (const key of frameTokens)
529
+ setIfMissing(values, key, 1);
530
+ if (options.elementStates === 'varied') {
531
+ for (const elementName of enabledTargets)
532
+ elementStates[elementName] ??= random.pick(variedStates);
199
533
  }
200
534
  for (const listName of dynamicLists) {
201
535
  if (lists[listName] !== undefined)
@@ -206,12 +540,11 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options) {
206
540
  return parsePreviewScenario({
207
541
  ...base,
208
542
  id: `${base.id}-${options.idPrefix}-${scenarioIndex + 1}`,
209
- description: `Generated plausible scenario ${scenarioIndex + 1} (seed ${sourceSeed})`,
543
+ description: `Generated source-aware scenario ${scenarioIndex + 1} (seed ${sourceSeed})`,
210
544
  values,
211
545
  lists,
212
546
  visibility,
213
547
  elementStates,
214
- selectedFrames,
215
548
  });
216
549
  });
217
550
  }
@@ -204,7 +204,7 @@ export const GuiGeneratedScenarioOptionsSchema = z
204
204
  .max(64)
205
205
  .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u)
206
206
  .default('generated'),
207
- preservePlaceholder: z.boolean().default(true),
207
+ preservePlaceholder: z.boolean().default(false),
208
208
  numericMinimum: z.number().min(-1_000_000_000).max(1_000_000_000).default(0),
209
209
  numericMaximum: z.number().min(-1_000_000_000).max(1_000_000_000).default(100),
210
210
  integerValues: z.boolean().default(true),
@@ -117,7 +117,27 @@ export function guiElementAttributeFidelity(key) {
117
117
  return 'unsupported';
118
118
  }
119
119
  function normalizeAssetPath(value) {
120
- return value.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/^\//u, '').toLowerCase();
120
+ return value
121
+ .replaceAll('\\', '/')
122
+ .replace(/^\/+|^\.\//u, '')
123
+ .replace(/\/+/gu, '/')
124
+ .toLowerCase();
125
+ }
126
+ function textureNodeFor(textureNodes, texturePath) {
127
+ const normalized = normalizeAssetPath(texturePath);
128
+ const extension = path.posix.extname(normalized);
129
+ const rasterExtensions = ['.png', '.bmp', '.tga', '.dds'];
130
+ const candidates = rasterExtensions.includes(extension)
131
+ ? [
132
+ normalized,
133
+ ...rasterExtensions
134
+ .filter((candidate) => candidate !== extension)
135
+ .map((candidate) => `${normalized.slice(0, -extension.length)}${candidate}`),
136
+ ]
137
+ : [normalized];
138
+ return candidates
139
+ .map((candidate) => textureNodes.get(candidate))
140
+ .find((value) => value !== undefined);
121
141
  }
122
142
  function scalarValue(value, constants) {
123
143
  const expanded = constants.get(value) ?? value;
@@ -363,7 +383,7 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
363
383
  }
364
384
  }
365
385
  }
366
- function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites, fonts) {
386
+ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites, fonts, globalTextColours) {
367
387
  const walk = (block) => {
368
388
  for (const assignment of assignments(block)) {
369
389
  if (assignment.value.type !== 'block')
@@ -457,14 +477,21 @@ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites,
457
477
  const colour = paradoxColour(firstScalarInsensitive(child, 'color', 'colour'));
458
478
  const borderColour = paradoxColour(firstScalarInsensitive(child, 'border_color', 'borderColor', 'border_colour'));
459
479
  const textColours = textColoursFrom(child);
460
- const id = deterministicId('gui_font', { path: file.displayPath, name });
480
+ const override = assignment.key.value === 'bitmapfont_override';
481
+ const id = deterministicId('gui_font', {
482
+ path: file.displayPath,
483
+ name,
484
+ override,
485
+ languages,
486
+ assetPaths,
487
+ });
461
488
  const font = {
462
489
  id,
463
490
  name,
464
491
  sourcePath: file.displayPath,
465
492
  location: nodeLocation(document, assignment, name),
466
493
  kind: 'bitmapfont',
467
- override: assignment.key.value === 'bitmapfont_override',
494
+ override,
468
495
  languages,
469
496
  assetPaths,
470
497
  ...(colour === undefined ? {} : { colour }),
@@ -492,6 +519,9 @@ function indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites,
492
519
  addEdge(edges, 'contains', fileNodeId, id, true, {}, font.location);
493
520
  }
494
521
  }
522
+ if (assignment.key.value === 'bitmapfonts') {
523
+ Object.assign(globalTextColours, textColoursFrom(child));
524
+ }
495
525
  walk(child);
496
526
  }
497
527
  };
@@ -773,8 +803,21 @@ function indexScriptedLocalisation(document, file, fileNodeId, nodes, edges, def
773
803
  const name = firstScalar(assignment.value, 'name')?.value;
774
804
  if (name === undefined)
775
805
  continue;
776
- const localisationKeys = childBlocks(assignment.value, 'text')
777
- .flatMap((textBlock) => firstScalar(textBlock, 'localisation_key')?.value ?? [])
806
+ const choices = childBlocks(assignment.value, 'text').flatMap((textBlock) => {
807
+ const localisationKey = firstScalarInsensitive(textBlock, 'localisation_key', 'localization_key');
808
+ if (localisationKey === undefined)
809
+ return [];
810
+ const trigger = childBlocks(textBlock, 'trigger')[0];
811
+ return [
812
+ {
813
+ localisationKey,
814
+ ...(trigger === undefined ? {} : { triggerExpression: raw(document, trigger) }),
815
+ rawSource: raw(document, textBlock),
816
+ },
817
+ ];
818
+ });
819
+ const localisationKeys = choices
820
+ .map(({ localisationKey }) => localisationKey)
778
821
  .sort((left, right) => compareCodeUnits(left, right));
779
822
  const id = deterministicId('scripted_localisation', { path: file.displayPath, name });
780
823
  const location = nodeLocation(document, assignment, name);
@@ -784,6 +827,7 @@ function indexScriptedLocalisation(document, file, fileNodeId, nodes, edges, def
784
827
  sourcePath: file.displayPath,
785
828
  location,
786
829
  localisationKeys,
830
+ choices,
787
831
  rawSource: raw(document, assignment),
788
832
  }, 'scripted localisation');
789
833
  addNode(nodes, {
@@ -792,7 +836,13 @@ function indexScriptedLocalisation(document, file, fileNodeId, nodes, edges, def
792
836
  name,
793
837
  path: file.displayPath,
794
838
  location,
795
- metadata: { localisationKeys },
839
+ metadata: {
840
+ localisationKeys,
841
+ choices: choices.map(({ localisationKey, triggerExpression }) => ({
842
+ localisationKey,
843
+ ...(triggerExpression === undefined ? {} : { triggerExpression }),
844
+ })),
845
+ },
796
846
  });
797
847
  addEdge(edges, 'contains', fileNodeId, id, true, {}, location);
798
848
  }
@@ -864,7 +914,7 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
864
914
  for (const [index, texturePath] of [sprite.texturePath, sprite.texturePath2].entries()) {
865
915
  if (texturePath === undefined)
866
916
  continue;
867
- const textureId = textureNodes.get(normalizeAssetPath(texturePath));
917
+ const textureId = textureNodeFor(textureNodes, texturePath);
868
918
  addEdge(edges, 'uses_texture', sprite.id, textureId ?? `texture:${normalizeAssetPath(texturePath)}`, textureId !== undefined, { texturePath, slot: index + 1 }, sprite.location);
869
919
  }
870
920
  const candidates = [
@@ -1069,6 +1119,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
1069
1119
  const elements = [];
1070
1120
  const sprites = [];
1071
1121
  const fonts = [];
1122
+ const textColours = {};
1072
1123
  const scriptedGuis = [];
1073
1124
  const animationSources = [];
1074
1125
  const scriptedLocalisation = [];
@@ -1190,7 +1241,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
1190
1241
  if (kind === 'gui_file')
1191
1242
  indexGuiElements(document, file, fileNodeId, nodes, edges, elements);
1192
1243
  if (kind === 'gfx_file') {
1193
- indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites, fonts);
1244
+ indexSpritesAndFonts(document, file, fileNodeId, nodes, edges, sprites, fonts, textColours);
1194
1245
  }
1195
1246
  if (kind === 'scripted_gui_file') {
1196
1247
  indexScriptedGuis(document, file, fileNodeId, nodes, edges, scriptedGuis);
@@ -1270,6 +1321,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
1270
1321
  elements,
1271
1322
  sprites,
1272
1323
  fonts,
1324
+ textColours: Object.fromEntries(Object.entries(textColours).sort(([left], [right]) => compareCodeUnits(left, right))),
1273
1325
  scriptedGuis,
1274
1326
  animationSources,
1275
1327
  scriptedLocalisation,
@@ -328,7 +328,7 @@ function localisationIconSpriteCandidates(token) {
328
328
  const normalized = token.startsWith('GFX_') ? token : `GFX_${token}`;
329
329
  return [...new Set([normalized, `${normalized}_texticon`, `${normalized}_text_icon`])];
330
330
  }
331
- function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = []) {
331
+ export function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = [], languages = ['l_english']) {
332
332
  const spriteNames = new Set(additionalSpriteNames);
333
333
  const fontNames = new Set();
334
334
  const textValues = new Set();
@@ -347,6 +347,37 @@ function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNam
347
347
  spriteNames.add(candidate);
348
348
  }
349
349
  }
350
+ const relatedScriptedGuiNames = new Set(graph.scriptedGuis
351
+ .filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)
352
+ .map(({ name }) => name));
353
+ let addedRelatedScriptedGui = true;
354
+ while (addedRelatedScriptedGui) {
355
+ addedRelatedScriptedGui = false;
356
+ for (const definition of graph.scriptedGuis) {
357
+ if (definition.parentScriptedGui === undefined ||
358
+ !relatedScriptedGuiNames.has(definition.parentScriptedGui) ||
359
+ relatedScriptedGuiNames.has(definition.name))
360
+ continue;
361
+ relatedScriptedGuiNames.add(definition.name);
362
+ addedRelatedScriptedGui = true;
363
+ }
364
+ }
365
+ const dynamicImageTokens = new Set(graph.scriptedGuis
366
+ .filter(({ name }) => relatedScriptedGuiNames.has(name))
367
+ .flatMap(({ propertyDefinitions }) => propertyDefinitions)
368
+ .flatMap(({ attributes }) => {
369
+ const image = Object.entries(attributes).find(([key]) => key.toLocaleLowerCase('en-US') === 'image')?.[1];
370
+ if (typeof image !== 'string')
371
+ return [];
372
+ const match = /^\[([^\]]+)\]$/u.exec(image);
373
+ return match?.[1] === undefined ? [] : [match[1]];
374
+ }));
375
+ for (const definition of graph.scriptedLocalisation) {
376
+ if (!dynamicImageTokens.has(definition.name))
377
+ continue;
378
+ for (const candidate of definition.localisationKeys)
379
+ spriteNames.add(candidate);
380
+ }
350
381
  const spritesByName = new Map(graph.sprites.map((sprite) => [sprite.name.toLocaleLowerCase('en-US'), sprite]));
351
382
  const selectedSprites = new Map();
352
383
  const pendingSprites = [...spriteNames];
@@ -366,7 +397,17 @@ function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNam
366
397
  }
367
398
  const selectedManifests = graph.animationSources.filter(({ sprite }) => selectedSprites.has(sprite.toLocaleLowerCase('en-US')));
368
399
  const selectedFontNames = new Set([...fontNames].map((name) => name.toLocaleLowerCase('en-US')));
369
- const selectedFonts = graph.fonts.filter(({ name }) => selectedFontNames.has(name.toLocaleLowerCase('en-US')));
400
+ const normalizedLanguages = new Set(languages.flatMap((language) => {
401
+ const normalized = language.toLocaleLowerCase('en-US');
402
+ return [normalized, normalized.replace(/^l_/u, '')];
403
+ }));
404
+ const selectedFonts = graph.fonts.filter(({ name, languages: fontLanguages }) => selectedFontNames.has(name.toLocaleLowerCase('en-US')) &&
405
+ (fontLanguages.length === 0 ||
406
+ fontLanguages.some((language) => {
407
+ const normalized = language.toLocaleLowerCase('en-US');
408
+ return (normalizedLanguages.has(normalized) ||
409
+ normalizedLanguages.has(normalized.replace(/^l_/u, '')));
410
+ })));
370
411
  const references = [
371
412
  ...[...selectedSprites.values()].flatMap(({ texturePath, texturePath2 }) => [texturePath, texturePath2].filter((value) => value !== undefined)),
372
413
  ...selectedFonts.flatMap(({ assetPaths }) => assetPaths),
@@ -665,7 +706,7 @@ export class ScriptedGuiStudio {
665
706
  const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions.files, localisation.files);
666
707
  const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`);
667
708
  assertTargetWindowAvailable(definitions.graph, windowName);
668
- const referencedPatterns = referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames);
709
+ const referencedPatterns = referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages);
669
710
  const exactReferenced = referencedPatterns.length === 0
670
711
  ? []
671
712
  : await this.scanner.scan(workspace, {
@@ -1100,7 +1141,7 @@ export class ScriptedGuiStudio {
1100
1141
  const scenario = parsePreviewScenario(input.scenario);
1101
1142
  const renderBudget = new RenderBudget();
1102
1143
  let proposedGraph = buildGuiSourceGraph(proposedFiles, this.engine.indexFiles(proposedFiles));
1103
- const proposedAssetPatterns = referencedAssetPatternsForWindow(proposedGraph, input.windowName);
1144
+ const proposedAssetPatterns = referencedAssetPatternsForWindow(proposedGraph, input.windowName, [], [scenario.language]);
1104
1145
  const exactProposedAssets = proposedAssetPatterns.length === 0
1105
1146
  ? []
1106
1147
  : await this.scanner.scan(workspace, {
@@ -509,7 +509,12 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
509
509
  const selectedSpriteNames = new Set(scene.elements.flatMap(({ sprite }) => (sprite === undefined ? [] : [sprite.spriteName])));
510
510
  const selectedSpriteIds = new Set(graph.sprites.filter(({ name }) => selectedSpriteNames.has(name)).map(({ id }) => id));
511
511
  const selectedFontNames = new Set(scene.elements.flatMap(({ text }) => (text?.fontName === undefined ? [] : [text.fontName])));
512
- const selectedFontIds = new Set(graph.fonts.filter(({ name }) => selectedFontNames.has(name)).map(({ id }) => id));
512
+ const sceneLanguage = scene.scenario.language.toLocaleLowerCase('en-US');
513
+ const selectedFontIds = new Set(graph.fonts
514
+ .filter(({ name, languages }) => selectedFontNames.has(name) &&
515
+ (languages.length === 0 ||
516
+ languages.some((language) => language.toLocaleLowerCase('en-US') === sceneLanguage)))
517
+ .map(({ id }) => id));
513
518
  const selectedManifestIds = new Set(graph.animationSources
514
519
  .filter(({ sprite }) => selectedSpriteNames.has(sprite))
515
520
  .map(({ id }) => id));
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '2.8.5';
3
+ export const PACKAGE_VERSION = '2.8.6';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
package/docs/gui.md CHANGED
@@ -33,15 +33,15 @@ Inspection checks missing assets and localisation, invalid sizes, overlap, clipp
33
33
 
34
34
  The renderer resolves sprites and fonts through the complete mod, dependency, and installed-game load order. Mod windows therefore render ordinary vanilla sprites, tiled vanilla panels, progress bars, masked shields, button frames, and localisation icons such as `£command_power` without copying game assets into the mod. Case differences in GFX and font identifiers do not prevent a valid asset from resolving.
35
35
 
36
- Bitmap text uses the language-appropriate HOI4 font definition and its real glyph atlas. Header, serif, compact, typewriter, black, inverted, and other declared faces keep their own glyph shapes, native metrics, face colour, border colour, language override, and font-specific `textcolors` palette. HOI4 localisation colour runs such as `§Y` and `§R` tint those same native glyph masks with the exact palette colour instead of switching to the tool font. The default language uses the base font rather than an unrelated language override, and the renderer recognizes installed-game atlases whose filename omits a page suffix declared by the matching `.fnt` file.
36
+ Bitmap text uses the language-appropriate HOI4 font definition and its real glyph atlas. Header, serif, compact, typewriter, black, inverted, and other declared faces keep their own glyph shapes, native metrics, face colour, border colour, language override, and font-specific `textcolors` palette. HOI4 localisation colour runs such as `§Y` and `§R` multiply the selected palette colour into the native glyph pixels, preserving the atlas's sharp antialiasing and dark shadow pixels instead of flattening the glyph into a blurred monochrome mask or switching to the tool font. The default language uses the base font rather than an unrelated language override, and the renderer recognizes installed-game atlases whose filename omits a page suffix declared by the matching `.fnt` file.
37
37
 
38
38
  Layout follows Clausewitz orientation and element-origin anchors, including `CENTER_LEFT`, `CENTER_RIGHT`, `CENTER_UP`, `CENTER_DOWN`, `origo`, `centerposition`, inherited container coordinate origins, local scale, and both `%` and `%%` relative dimensions. Text boxes use native font metrics together with `maxWidth`, `maxHeight`, alignment, wrapping, and `fixedsize` clipping. Cornered tile sprites use fixed nine-slice borders and optional center tiling; progress bars composite their filled and background textures; masked shields composite their background and mask textures.
39
39
 
40
40
  Scripted-GUI composition follows `parent_scripted_gui` chains even when the linked child window is a top-level sibling in the `.gui` file. Literal `always = yes` and `always = no` visibility and click-enabled blocks are applied automatically. A scenario's `values` object supplies concrete runtime values in one place: variable names resolve numeric text, scripted-localisation names resolve dynamic labels, a progress-bar element name sets its fill value, and element keys ending in `.image`, `.frame`, `.x`, `.y`, `.visible`, or `.enabled` update that visual property. Scripted-GUI property expressions such as `image = "[GetMeterSprite]"` or `x = meter_fill_width` consume matching scenario values automatically. Dynamic lists instantiate their declared `entry_container` or `country_scope_entry_container`; each scenario row can select the country template with `countryScope`, choose an explicit `entryContainer`, and provide row-scoped text, image, frame, position, visibility, and enabled values.
41
41
 
42
- `hoi4.gui_inspect` and `hoi4.gui_render` generate a plausible seeded runtime scenario by default. The generator discovers numeric and text tokens in the selected window, localisation, and scripted-GUI properties; fills meters; populates dynamic lists; chooses animated frames; varies condition-controlled visibility and enabled controls; and leaves values explicitly supplied by the caller unchanged. The same artifact gallery retains the unresolved placeholder scenario and every explicit `relatedScenarios` entry, so an agent can compare plausible data, exact authored cases, and `[dynamic_loc]` or `[X]` fallbacks in one result.
42
+ `hoi4.gui_inspect` and `hoi4.gui_render` generate a plausible seeded runtime scenario by default. The generator follows the complete `parent_scripted_gui` surface, discovers numeric and text tokens in GUI localisation and scripted-GUI properties, chooses real scripted-localisation text and sprite candidates, applies simple source thresholds, fills meters within their declared ranges, populates dynamic lists with coherent rows, selects one overlapping tab pane, keeps active and idle controls mutually exclusive, and leaves values explicitly supplied by the caller unchanged. Normal controls and animation frames remain stable unless variation is explicitly requested. Every explicit `relatedScenarios` entry is rendered beside the generated view.
43
43
 
44
- Generation is reproducible. Set `generatedScenarios.seed` to any stable string for an exact rerun, use `count` for several plausible versions, or configure numeric bounds, integer or decimal values, list row bounds, text samples, visibility probability, and state variation. Set `enabled` to `false` when only the supplied scenario should render, and set `preservePlaceholder` to `false` when the fallback view is not needed.
44
+ Generation is reproducible. Set `generatedScenarios.seed` to any stable string for an exact rerun, use `count` for several plausible versions, or configure numeric bounds, integer or decimal values, list row bounds, text samples, visibility probability, and state variation. The generated view is the default. Set `enabled` to `false` when only the supplied placeholder scenario should render, or set `preservePlaceholder` to `true` to include the placeholder beside generated scenarios.
45
45
 
46
46
  ```json
47
47
  {
@@ -93,6 +93,6 @@ Large inspections, renders, and rewrites send periodic MCP progress heartbeats w
93
93
 
94
94
  The renderer does not run the game engine. Each render includes a fidelity report that separates fields it models from fields it approximates, ignores, cannot resolve, or does not support. Treat that report as part of the review.
95
95
 
96
- The generated primary view replaces discovered numeric and text runtime tokens with plausible values. The retained fallback view renders unresolved numeric values such as `[?variable|format]` as `[X]` and unresolved text-returning scripted or scoped localisation such as `[GetStatusText]`, `[FROM.GetName]`, and `[?leader_scope.GetName]` as `[dynamic_loc]`. Put `variable` or `GetStatusText` in the supplied scenario's `values` object when an exact value matters; explicit values always take precedence over generated ones and can drive the related meter or state-dependent controls. Supported HOI4 `§` localisation colour controls use the active font's palette and `§!` restores the face colour.
96
+ The generated primary view replaces discovered numeric and text runtime tokens with plausible values. Set `generatedScenarios.preservePlaceholder` to `true`, or disable generation, when the same run should also show unresolved numeric values such as `[?variable|format]` as `[X]` and unresolved text-returning scripted or scoped localisation such as `[GetStatusText]`, `[FROM.GetName]`, and `[?leader_scope.GetName]` as `[dynamic_loc]`. Put `variable` or `GetStatusText` in the supplied scenario's `values` object when an exact value matters; explicit values always take precedence over generated ones and can drive the related meter or state-dependent controls. Supported HOI4 `§` localisation colour controls use the active font's palette and `§!` restores the face colour.
97
97
 
98
98
  A rewrite stops if malformed or unsupported GUI script makes the requested change ambiguous. Frame-sheet animation, nine-slice tiling, progress composition, masking, and the primary and secondary textures referenced by vanilla GFX definitions render offline. Engine shader programs, hardcoded controls, and dynamic values that are not supplied by a scenario remain fidelity-report entries; an unexecuted shader never hides its resolved base texture.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hoi4-agent-tools",
3
3
  "mcpName": "io.github.klimPaskov/hoi4-agent-tools",
4
- "version": "2.8.5",
4
+ "version": "2.8.6",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/configuration.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/configuration.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/continuous-focus-palette.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/continuous-focus-palette.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/custom-weighted-pool.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/custom-weighted-pool.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/event-feature-manifest.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/event-feature-manifest.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/focus-plan.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/focus-plan.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/focus-planning-sidecar.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/focus-planning-sidecar.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/gui-animation-source.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/gui-animation-source.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/gui-helper.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/gui-helper.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/gui-scenario.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/gui-scenario.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/map-operation.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/map-operation.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "oneOf": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/operation-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/operation-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-analysis-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-analysis-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-compare-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-compare-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-evaluate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-evaluate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-inspect-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-inspect-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-render-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-render-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-scenario-set.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-scenario-set.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-sequence-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-sequence-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-simulate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-simulate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.5/schemas/probability-sweep-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.8.6/schemas/probability-sweep-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.klimPaskov/hoi4-agent-tools",
4
4
  "title": "HOI4 Agent Tools",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
- "version": "2.8.5",
6
+ "version": "2.8.6",
7
7
  "repository": {
8
8
  "url": "https://github.com/klimPaskov/hoi4-agent-tools",
9
9
  "source": "github"
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "hoi4-agent-tools",
16
- "version": "2.8.5",
16
+ "version": "2.8.6",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }