hoi4-agent-tools 1.2.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +6 -7
  3. package/dist/hoi4_agent_tools/core/artifacts.js +109 -4
  4. package/dist/hoi4_agent_tools/core/diagnostic-collector.js +28 -8
  5. package/dist/hoi4_agent_tools/core/diff.js +162 -4
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -0
  7. package/dist/hoi4_agent_tools/core/index.js +17 -6
  8. package/dist/hoi4_agent_tools/core/render-budget.js +5 -3
  9. package/dist/hoi4_agent_tools/core/scanner.js +3 -2
  10. package/dist/hoi4_agent_tools/core/source/limits.js +4 -1
  11. package/dist/hoi4_agent_tools/core/workspace.js +31 -3
  12. package/dist/hoi4_agent_tools/event/graph.js +28 -6
  13. package/dist/hoi4_agent_tools/event/limits.js +1 -1
  14. package/dist/hoi4_agent_tools/event/service.js +103 -7
  15. package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
  16. package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
  17. package/dist/hoi4_agent_tools/focus/render.js +27 -16
  18. package/dist/hoi4_agent_tools/gui/assets.js +9 -2
  19. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
  20. package/dist/hoi4_agent_tools/gui/layout.js +126 -27
  21. package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
  22. package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
  23. package/dist/hoi4_agent_tools/gui/studio.js +23 -8
  24. package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
  25. package/dist/hoi4_agent_tools/map/model.js +22 -4
  26. package/dist/hoi4_agent_tools/map/service.js +4 -3
  27. package/dist/hoi4_agent_tools/map/validation.js +3 -2
  28. package/dist/hoi4_agent_tools/mcp/server/create.js +1 -3
  29. package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
  30. package/dist/hoi4_agent_tools/mcp/tools/event.js +6 -6
  31. package/dist/hoi4_agent_tools/mcp/tools/focus.js +149 -224
  32. package/dist/hoi4_agent_tools/mcp/tools/gui.js +35 -25
  33. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
  34. package/dist/hoi4_agent_tools/runtime.js +112 -2
  35. package/dist/hoi4_agent_tools/schemas/common.js +5 -1
  36. package/dist/hoi4_agent_tools/version.js +1 -1
  37. package/docs/events.md +4 -5
  38. package/docs/focus.md +5 -4
  39. package/docs/gui.md +5 -1
  40. package/docs/map.md +1 -1
  41. package/docs/setup.md +9 -6
  42. package/package.json +1 -1
  43. package/server.json +2 -2
  44. package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
@@ -0,0 +1,99 @@
1
+ import { promisify } from 'node:util';
2
+ import { gzip } from 'node:zlib';
3
+ const gzipAsync = promisify(gzip);
4
+ export const GUI_INSPECTION_COMPRESSION_THRESHOLD = 134_217_728;
5
+ export const GUI_INSPECTION_FULL_GRAPH_NODE_LIMIT = 50_000;
6
+ function isWorkspaceSource(path) {
7
+ return path.startsWith('mod:') || path.startsWith('fixture:');
8
+ }
9
+ const workspaceSeedKinds = new Set([
10
+ 'gui_file',
11
+ 'gfx_file',
12
+ 'scripted_gui_file',
13
+ 'scripted_localisation_file',
14
+ 'gui_element',
15
+ 'sprite',
16
+ 'texture',
17
+ 'font',
18
+ 'scripted_gui',
19
+ 'scripted_localisation',
20
+ 'animation_source_manifest',
21
+ 'animation_source_frame',
22
+ ]);
23
+ function isWorkspaceGraphSeed(node) {
24
+ return isWorkspaceSource(node.path) && workspaceSeedKinds.has(node.kind);
25
+ }
26
+ function graphCounts(graph) {
27
+ return {
28
+ nodes: graph.nodes.length,
29
+ edges: graph.edges.length,
30
+ elements: graph.elements.length,
31
+ sprites: graph.sprites.length,
32
+ fonts: graph.fonts.length,
33
+ scriptedGuis: graph.scriptedGuis.length,
34
+ animationSources: graph.animationSources.length,
35
+ scriptedLocalisation: graph.scriptedLocalisation.length,
36
+ localisation: graph.localisation.length,
37
+ };
38
+ }
39
+ /**
40
+ * A complete vanilla GUI inventory is useful for linking but wasteful to repeat in every
41
+ * artifact. Large reports retain the workspace overlay, the selected scene, and every
42
+ * directly connected node while recording exact full-inventory counts.
43
+ */
44
+ export function projectGuiGraphForArtifact(graph, selectedSourceIds = [], fullGraphNodeLimit = GUI_INSPECTION_FULL_GRAPH_NODE_LIMIT) {
45
+ if (graph.nodes.length <= fullGraphNodeLimit)
46
+ return { graph };
47
+ const retainedIds = new Set(selectedSourceIds);
48
+ for (const node of graph.nodes)
49
+ if (isWorkspaceGraphSeed(node))
50
+ retainedIds.add(node.id);
51
+ const retainedEdges = graph.edges.filter(({ from, to }) => retainedIds.has(from) || retainedIds.has(to));
52
+ for (const edge of retainedEdges) {
53
+ retainedIds.add(edge.from);
54
+ retainedIds.add(edge.to);
55
+ }
56
+ const retainedLocalisation = new Set(graph.nodes
57
+ .filter(({ id, kind }) => kind === 'localisation' && retainedIds.has(id))
58
+ .map(({ name, path }) => `${path}\u0000${name}`));
59
+ const retainedGraph = {
60
+ ...graph,
61
+ nodes: graph.nodes.filter(({ id }) => retainedIds.has(id)),
62
+ edges: retainedEdges,
63
+ elements: graph.elements.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
64
+ sprites: graph.sprites.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
65
+ fonts: graph.fonts.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
66
+ scriptedGuis: graph.scriptedGuis.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
67
+ animationSources: graph.animationSources.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
68
+ scriptedLocalisation: graph.scriptedLocalisation.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
69
+ localisation: graph.localisation.filter(({ key, sourcePath }) => retainedLocalisation.has(`${sourcePath}\u0000${key}`)),
70
+ };
71
+ return {
72
+ graph: retainedGraph,
73
+ projection: {
74
+ mode: 'workspace-overlay-and-selected',
75
+ fullCounts: graphCounts(graph),
76
+ returnedCounts: graphCounts(retainedGraph),
77
+ },
78
+ };
79
+ }
80
+ /** Keeps small reports directly readable and compresses very large source graphs deterministically. */
81
+ export async function encodeGuiInspectionArtifact(name, json, compressionThreshold = GUI_INSPECTION_COMPRESSION_THRESHOLD) {
82
+ const uncompressedBytes = Buffer.byteLength(json);
83
+ if (uncompressedBytes <= compressionThreshold) {
84
+ return {
85
+ name,
86
+ mimeType: 'application/json',
87
+ content: json,
88
+ compressed: false,
89
+ uncompressedBytes,
90
+ };
91
+ }
92
+ return {
93
+ name: `${name}.gz`,
94
+ mimeType: 'application/gzip',
95
+ content: await gzipAsync(Buffer.from(json), { level: 1 }),
96
+ compressed: true,
97
+ uncompressedBytes,
98
+ };
99
+ }
@@ -7,6 +7,8 @@ import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES, GUI_S
7
7
  import { guiElementAttributeFidelity } from './source-graph.js';
8
8
  import { emptyFidelityReport } from './types.js';
9
9
  const clickableTypes = /(?:button|checkbox|editbox|scrollbar|progressbar)/iu;
10
+ const numericDynamicPlaceholder = '[X]';
11
+ const textDynamicPlaceholder = '[dynamic_loc]';
10
12
  const partialSpriteSemantics = {
11
13
  textSpriteType: {
12
14
  field: 'text_sprite_semantics',
@@ -40,8 +42,55 @@ function scalarString(value) {
40
42
  return String(value);
41
43
  return undefined;
42
44
  }
45
+ const defaultTextColour = '#f5f2e8';
46
+ const hoi4TextColours = {
47
+ B: '#4aa3df',
48
+ C: '#55d6d6',
49
+ G: '#56b870',
50
+ H: '#e7b454',
51
+ L: '#9aa7b3',
52
+ M: '#d16bd4',
53
+ O: '#e58b3c',
54
+ R: '#e05a5a',
55
+ T: '#4bc6b9',
56
+ W: defaultTextColour,
57
+ Y: '#f1c75b',
58
+ b: '#4aa3df',
59
+ g: '#87939e',
60
+ };
43
61
  function visibleHoiText(value) {
44
- return value.replace(/\u00a7[A-Za-z0-9!]/gu, '').replace(/\u00a3[^\u00a3\s]+/gu, '\u25c6');
62
+ const characters = [];
63
+ const colours = [];
64
+ let colour;
65
+ let hasColourMarkup = false;
66
+ for (let index = 0; index < value.length; index += 1) {
67
+ const character = value[index] ?? '';
68
+ const next = value[index + 1];
69
+ if (character === '\u00a7' && next !== undefined && /^[A-Za-z0-9!]$/u.test(next)) {
70
+ hasColourMarkup = true;
71
+ colour = next === '!' ? undefined : hoi4TextColours[next];
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (character === '\u00a3') {
76
+ let cursor = index + 1;
77
+ while (cursor < value.length && !/[\s\u00a3]/u.test(value[cursor] ?? ''))
78
+ cursor += 1;
79
+ characters.push('\u25c6');
80
+ colours.push(colour);
81
+ index = cursor - 1;
82
+ continue;
83
+ }
84
+ if (character === '\\' && next === 'n') {
85
+ characters.push('\n');
86
+ colours.push(colour);
87
+ index += 1;
88
+ continue;
89
+ }
90
+ characters.push(character);
91
+ colours.push(colour);
92
+ }
93
+ return { text: characters.join(''), colours, hasColourMarkup };
45
94
  }
46
95
  function scalarNumber(value, reference) {
47
96
  if (typeof value === 'number')
@@ -167,13 +216,17 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
167
216
  const key = match[1] ?? '';
168
217
  const replacement = rowValues?.[key] ?? scenario.variables[key] ?? scenario.scriptedGui[key];
169
218
  if (replacement === undefined) {
170
- unresolved.push(`[?${key}]`);
171
- return `[?${key}]`;
219
+ unresolved.push(match[0]);
220
+ return /(?:^|\.)Get[A-Za-z0-9_]+$/u.test(key)
221
+ ? textDynamicPlaceholder
222
+ : numericDynamicPlaceholder;
172
223
  }
173
224
  return String(replacement);
174
225
  }, 'GUI variable substitution');
175
226
  text = replaceTextBounded(text, /\[([A-Za-z0-9_.:-]+)\]/gu, (match) => {
176
227
  const key = match[1] ?? '';
228
+ if (key === 'X' || key === 'dynamic_loc')
229
+ return match[0];
177
230
  const countryKey = key.replace(/^(?:ROOT|This)\./u, '');
178
231
  const replacement = rowValues?.[countryKey] ??
179
232
  scenario.country?.[countryKey] ??
@@ -181,16 +234,16 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
181
234
  scenario.scriptedGui[key];
182
235
  if (replacement === undefined) {
183
236
  unresolved.push(match[0]);
184
- return match[0];
237
+ return textDynamicPlaceholder;
185
238
  }
186
239
  return String(replacement);
187
240
  }, 'GUI scope substitution');
188
241
  return { text, unresolved: [...new Set(unresolved)].sort(), missingLocalisation };
189
242
  }
190
- function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
191
- const paragraphs = text.replaceAll('\\n', '\n').split('\n');
243
+ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work) {
192
244
  const lines = [];
193
245
  const widths = [];
246
+ const colourLines = [];
194
247
  const missingGlyphs = new Set();
195
248
  const retainMissingGlyphs = (values) => {
196
249
  for (const value of values) {
@@ -201,12 +254,44 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
201
254
  };
202
255
  let lineHeight = fontSize * 1.2;
203
256
  let metricSource = 'approximation';
204
- for (const paragraph of paragraphs) {
257
+ const commitLine = (words) => {
258
+ const characters = [];
259
+ const lineColours = [];
260
+ for (const [index, word] of words.entries()) {
261
+ if (index > 0) {
262
+ characters.push(' ');
263
+ lineColours.push(lineColours.at(-1) ?? word.colours[0]);
264
+ }
265
+ characters.push(word.text);
266
+ lineColours.push(...word.colours);
267
+ }
268
+ const committedText = characters.join('');
269
+ work.spendTextLayout('committed text line measurement');
270
+ const committed = catalog.measureText(fontName, committedText, fontSize);
271
+ lines.push(committedText);
272
+ widths.push(committed.width);
273
+ colourLines.push(lineColours);
274
+ retainMissingGlyphs(committed.missingGlyphs);
275
+ lineHeight = committed.lineHeight;
276
+ metricSource = committed.source;
277
+ };
278
+ let paragraphStart = 0;
279
+ for (let paragraphEnd = 0; paragraphEnd <= text.length; paragraphEnd += 1) {
280
+ if (paragraphEnd < text.length && text[paragraphEnd] !== '\n')
281
+ continue;
205
282
  work.spendTextLayout('text paragraph layout');
206
- const words = paragraph.split(/\s+/u).filter((word) => word.length > 0);
283
+ const paragraph = text.slice(paragraphStart, paragraphEnd);
284
+ const paragraphColours = colours.slice(paragraphStart, paragraphEnd);
285
+ const words = [...paragraph.matchAll(/\S+/gu)].map((match) => {
286
+ const start = match.index;
287
+ const word = match[0];
288
+ return { text: word, colours: paragraphColours.slice(start, start + word.length) };
289
+ });
290
+ paragraphStart = paragraphEnd + 1;
207
291
  if (words.length === 0) {
208
292
  lines.push('');
209
293
  widths.push(0);
294
+ colourLines.push([]);
210
295
  continue;
211
296
  }
212
297
  let currentWords = [];
@@ -215,22 +300,17 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
215
300
  let previousWordWidth = 0;
216
301
  for (const word of words) {
217
302
  work.spendTextLayout('text word measurement');
218
- const measuredWord = catalog.measureText(fontName, word, fontSize);
303
+ const measuredWord = catalog.measureText(fontName, word.text, fontSize);
219
304
  retainMissingGlyphs(measuredWord.missingGlyphs);
220
305
  let candidateWidth = measuredWord.width;
221
306
  if (previousWord !== undefined) {
222
307
  work.spendTextLayout('text word-boundary measurement');
223
- const boundary = catalog.measureText(fontName, `${previousWord} ${word}`, fontSize);
308
+ const boundary = catalog.measureText(fontName, `${previousWord} ${word.text}`, fontSize);
224
309
  retainMissingGlyphs(boundary.missingGlyphs);
225
310
  candidateWidth = currentWidth + boundary.width - previousWordWidth;
226
311
  }
227
312
  if (maximumWidth > 0 && candidateWidth > maximumWidth && currentWords.length > 0) {
228
- const committedText = currentWords.join(' ');
229
- work.spendTextLayout('committed text line measurement');
230
- const committed = catalog.measureText(fontName, committedText, fontSize);
231
- lines.push(committedText);
232
- widths.push(committed.width);
233
- retainMissingGlyphs(committed.missingGlyphs);
313
+ commitLine(currentWords);
234
314
  currentWords = [word];
235
315
  currentWidth = measuredWord.width;
236
316
  }
@@ -238,26 +318,40 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
238
318
  currentWords.push(word);
239
319
  currentWidth = candidateWidth;
240
320
  }
241
- previousWord = word;
321
+ previousWord = word.text;
242
322
  previousWordWidth = measuredWord.width;
243
323
  }
244
- const committedText = currentWords.join(' ');
245
- work.spendTextLayout('committed text line measurement');
246
- const committed = catalog.measureText(fontName, committedText, fontSize);
247
- lines.push(committedText);
248
- widths.push(committed.width);
249
- retainMissingGlyphs(committed.missingGlyphs);
250
- lineHeight = committed.lineHeight;
251
- metricSource = committed.source;
324
+ commitLine(currentWords);
252
325
  }
253
326
  return {
254
327
  lines,
255
328
  widths,
329
+ colourLines,
256
330
  lineHeight,
257
331
  metricSource,
258
332
  missingGlyphs: [...missingGlyphs].sort((a, b) => a - b),
259
333
  };
260
334
  }
335
+ function colourRunsForLine(catalog, fontName, line, colours, fontSize, work) {
336
+ if (!colours.some((colour) => colour !== undefined) || line.length === 0)
337
+ return [];
338
+ const runs = [];
339
+ let start = 0;
340
+ let offsetX = 0;
341
+ while (start < line.length) {
342
+ const colour = colours[start] ?? defaultTextColour;
343
+ let end = start + 1;
344
+ while (end < line.length && (colours[end] ?? defaultTextColour) === colour)
345
+ end += 1;
346
+ const text = line.slice(start, end);
347
+ work.spendTextLayout('localisation colour-run measurement');
348
+ const width = catalog.measureText(fontName, text, fontSize).width;
349
+ runs.push({ text, colour, offsetX, width });
350
+ offsetX += width;
351
+ start = end;
352
+ }
353
+ return runs;
354
+ }
261
355
  function frameFor(element, sprite, scenario) {
262
356
  const frameCount = Math.max(1, sprite.frameCount);
263
357
  const selected = scenario.selectedFrames[element.name] ?? scenario.selectedFrames[sprite.name];
@@ -439,7 +533,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
439
533
  displayText = `${displayText} — ${displayText} — ${displayText}`;
440
534
  if (state === 'missing-localisation')
441
535
  displayText = `\u00a7R${rawText}_MISSING\u00a7!`;
442
- displayText = visibleHoiText(displayText);
536
+ const visibleText = visibleHoiText(displayText);
537
+ displayText = visibleText.text;
443
538
  context.work.admitText(displayText, `GUI text for ${definition.name}`);
444
539
  const fontName = scalarString(property(definition.attributes, 'font', 'buttonFont'));
445
540
  const resolvedFontMetrics = catalog.resolvedFontMetrics(fontName);
@@ -450,10 +545,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
450
545
  16;
451
546
  const maxWidth = (scalarNumber(property(definition.attributes, 'maxWidth'), width / scale) ?? width / scale) *
452
547
  scale;
453
- const wrapped = wrapText(catalog, fontName, displayText, fontSize * scale, maxWidth, context.work);
548
+ const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work);
454
549
  const glyphLines = [];
455
550
  for (const line of wrapped.lines)
456
551
  glyphLines.push(await catalog.shapeText(fontName, line, fontSize * scale));
552
+ const colourRuns = visibleText.hasColourMarkup
553
+ ? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work))
554
+ : undefined;
457
555
  const maximumLineWidth = wrapped.widths.reduce((maximum, value) => Math.max(maximum, value), 0);
458
556
  if (width === 0)
459
557
  width = maximumLineWidth;
@@ -476,6 +574,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
476
574
  overflowX: width > 0 && measuredWidth > width + 0.01,
477
575
  overflowY: height > 0 && measuredHeight > height + 0.01,
478
576
  unresolvedTokens: resolved.unresolved,
577
+ ...(colourRuns === undefined ? {} : { colourRuns }),
479
578
  };
480
579
  if (wrapped.metricSource === 'approximation')
481
580
  addFidelity(fidelity, 'approximated', 'font_metrics', `No supplied font metrics for ${fontName ?? '<default>'}.`, definition);
@@ -86,6 +86,22 @@ function renderText(element, toolText) {
86
86
  (glyphLine === undefined || glyphLine.source === 'deterministic-fallback'
87
87
  ? text.lineHeight * 0.8
88
88
  : glyphLine.baseline);
89
+ const colourRuns = text.colourRuns?.[index] ?? [];
90
+ if (colourRuns.length > 0) {
91
+ const runWidth = colourRuns.reduce((maximum, run) => Math.max(maximum, run.offsetX + run.width), 0);
92
+ const horizontalScale = runWidth > 0 && width > 0 ? width / runWidth : 1;
93
+ return `<g data-hoi4-colour-runs="true">${colourRuns
94
+ .map((run) => toolText.render(run.text, {
95
+ x: originX + run.offsetX * horizontalScale,
96
+ y: baseline,
97
+ fontSize: text.fontSize,
98
+ fill: run.colour,
99
+ stroke: '#12151a',
100
+ strokeWidth: 0.6,
101
+ ...(run.width > 0 ? { targetWidth: run.width * horizontalScale } : {}),
102
+ }))
103
+ .join('')}</g>`;
104
+ }
89
105
  if (glyphLine?.source === 'fontkit-path') {
90
106
  const horizontalScale = glyphLine.width > 0 && width > 0 ? width / glyphLine.width : 1;
91
107
  return `<g data-font-sha256="${glyphLine.sourceHash}">${glyphLine.glyphs
@@ -736,19 +736,19 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
736
736
  }
737
737
  }
738
738
  for (const element of elements) {
739
- const spriteName = [element.attributes.spriteType, element.attributes.quadTextureSprite].find((value) => typeof value === 'string');
739
+ const spriteName = [element.attributes.spriteType, element.attributes.quadTextureSprite].find((value) => typeof value === 'string' && value.trim().length > 0);
740
740
  if (spriteName !== undefined) {
741
741
  const sprite = spriteByName.get(spriteName);
742
742
  addEdge(edges, 'uses_sprite', element.id, sprite?.id ?? `sprite:${spriteName}`, sprite !== undefined, { spriteName }, element.location);
743
743
  }
744
- const fontName = [element.attributes.font, element.attributes.buttonFont].find((value) => typeof value === 'string');
744
+ const fontName = [element.attributes.font, element.attributes.buttonFont].find((value) => typeof value === 'string' && value.trim().length > 0);
745
745
  if (fontName !== undefined) {
746
746
  const font = fontByName.get(fontName);
747
747
  addEdge(edges, 'uses_font', element.id, font?.id ?? `font:${fontName}`, font !== undefined, { fontName }, element.location);
748
748
  }
749
- for (const key of ['text', 'buttonText', 'pdx_tooltip', 'pdx_tooltip_delayed', 'hint_tag']) {
749
+ for (const key of localisationAttributeNames) {
750
750
  const value = element.attributes[key];
751
- if (typeof value !== 'string')
751
+ if (typeof value !== 'string' || value.trim().length === 0)
752
752
  continue;
753
753
  const entry = localisationByKey.get(value);
754
754
  addEdge(edges, 'uses_localisation', element.id, entry === undefined ? `localisation:${value}` : deterministicId('gui_loc', entry), entry !== undefined, { field: key, key: value }, element.location);
@@ -800,6 +800,24 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
800
800
  addEdge(edges, 'decision_category_entry', pending.from, target?.id ?? `scripted_gui:${pending.target}`, target !== undefined, { scriptedGui: pending.target }, pending.location);
801
801
  }
802
802
  }
803
+ const localisationAttributeNames = [
804
+ 'text',
805
+ 'buttonText',
806
+ 'pdx_tooltip',
807
+ 'pdx_tooltip_delayed',
808
+ 'hint_tag',
809
+ ];
810
+ function referencedGuiLocalisationKeys(elements, scriptedLocalisation) {
811
+ const keys = new Set(scriptedLocalisation.flatMap(({ localisationKeys }) => localisationKeys));
812
+ for (const element of elements) {
813
+ for (const attribute of localisationAttributeNames) {
814
+ const value = element.attributes[attribute];
815
+ if (typeof value === 'string' && value.trim().length > 0)
816
+ keys.add(value);
817
+ }
818
+ }
819
+ return keys;
820
+ }
803
821
  function skippedInventoryCouldResolve(edge, sharedIndex) {
804
822
  switch (edge.kind) {
805
823
  case 'uses_sprite':
@@ -837,6 +855,10 @@ function* referenceDiagnostics(edges) {
837
855
  edge.kind === 'animation_source_frame' ||
838
856
  (edge.kind === 'uses_font' && edge.metadata.assetPath !== undefined))
839
857
  continue;
858
+ if (edge.location !== undefined &&
859
+ !edge.location.path.startsWith('mod:') &&
860
+ !edge.location.path.startsWith('fixture:'))
861
+ continue;
840
862
  const partial = edge.partialInventory === true;
841
863
  yield {
842
864
  code: partial ? 'GUI_REFERENCE_UNRESOLVED_PARTIAL' : 'GUI_REFERENCE_UNRESOLVED',
@@ -905,6 +927,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
905
927
  const fontAssetNodes = new Map();
906
928
  const animationFrameNodes = new Map();
907
929
  const pendingDecisionEdges = [];
930
+ const localisationFiles = [];
908
931
  for (const file of [...activeFiles].sort((a, b) => compareCodeUnits(a.displayPath, b.displayPath))) {
909
932
  sourceHashes[file.displayPath] = file.sha256;
910
933
  if (sharedIndex.isSourceSkipped(file.displayPath))
@@ -968,28 +991,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
968
991
  }
969
992
  }
970
993
  if (kind === 'localisation_file' && fileNodeId !== undefined) {
971
- const document = parseLocalisation(file.bytes, file.displayPath);
972
- diagnostics.pushMany(document.diagnostics);
973
- for (const entry of document.entries) {
974
- const value = {
975
- key: entry.key,
976
- language: entry.language,
977
- value: entry.value,
978
- sourcePath: file.displayPath,
979
- location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
980
- };
981
- addDomainEntry(localisation, value, 'localisation');
982
- const id = deterministicId('gui_loc', value);
983
- addNode(nodes, {
984
- id,
985
- kind: 'localisation',
986
- name: entry.key,
987
- path: file.displayPath,
988
- ...(value.location === undefined ? {} : { location: value.location }),
989
- metadata: { language: entry.language, value: entry.value },
990
- });
991
- addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
992
- }
994
+ localisationFiles.push({ file, fileNodeId });
993
995
  continue;
994
996
  }
995
997
  if (kind === 'animation_source_manifest' && fileNodeId !== undefined) {
@@ -999,13 +1001,15 @@ export function buildGuiSourceGraph(files, sharedIndex) {
999
1001
  if (kind === undefined || fileNodeId === undefined) {
1000
1002
  if (/\.(?:txt|gui|gfx)$/u.test(normalized)) {
1001
1003
  const document = parseClausewitz(file.bytes, file.displayPath);
1002
- diagnostics.pushMany(document.diagnostics);
1004
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1005
+ diagnostics.pushMany(document.diagnostics);
1003
1006
  indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1004
1007
  }
1005
1008
  continue;
1006
1009
  }
1007
1010
  const document = parseClausewitz(file.bytes, file.displayPath);
1008
- diagnostics.pushMany(document.diagnostics);
1011
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1012
+ diagnostics.pushMany(document.diagnostics);
1009
1013
  if (kind === 'gui_file')
1010
1014
  indexGuiElements(document, file, fileNodeId, nodes, edges, elements);
1011
1015
  if (kind === 'gfx_file') {
@@ -1019,6 +1023,35 @@ export function buildGuiSourceGraph(files, sharedIndex) {
1019
1023
  }
1020
1024
  indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1021
1025
  }
1026
+ const referencedLocalisationKeys = referencedGuiLocalisationKeys(elements, scriptedLocalisation);
1027
+ for (const { file, fileNodeId } of localisationFiles.sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
1028
+ compareCodeUnits(left.file.displayPath, right.file.displayPath))) {
1029
+ const document = parseLocalisation(file.bytes, file.displayPath);
1030
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1031
+ diagnostics.pushMany(document.diagnostics);
1032
+ for (const entry of document.entries) {
1033
+ if (!referencedLocalisationKeys.has(entry.key))
1034
+ continue;
1035
+ const value = {
1036
+ key: entry.key,
1037
+ language: entry.language,
1038
+ value: entry.value,
1039
+ sourcePath: file.displayPath,
1040
+ location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
1041
+ };
1042
+ addDomainEntry(localisation, value, 'localisation');
1043
+ const id = deterministicId('gui_loc', value);
1044
+ addNode(nodes, {
1045
+ id,
1046
+ kind: 'localisation',
1047
+ name: entry.key,
1048
+ path: file.displayPath,
1049
+ ...(value.location === undefined ? {} : { location: value.location }),
1050
+ metadata: { language: entry.language, value: entry.value },
1051
+ });
1052
+ addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
1053
+ }
1054
+ }
1022
1055
  linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scriptedLocalisation, localisation, textureNodes, fontAssetNodes, pendingDecisionEdges);
1023
1056
  linkAnimationSources(nodes, edges, animationSources, sprites, textureNodes, animationFrameNodes);
1024
1057
  markPartialReferenceEdges(edges, sharedIndex);
@@ -125,7 +125,18 @@ function under(roots, suffixes) {
125
125
  return suffixes.map((suffix) => `${normalized}/${suffix}`);
126
126
  });
127
127
  }
128
- function guiDefinitionPatterns(workspace, languages = ['l_english']) {
128
+ function guiDefinitionPatterns(workspace) {
129
+ const roots = workspace.registration.roots;
130
+ return [
131
+ ...new Set([
132
+ ...under(roots.interface, ['**/*.gui', '**/*.gfx']),
133
+ ...under(roots.gfx, ['**/*.gfx']),
134
+ ...under(roots.scriptedGui, ['**/*.txt']),
135
+ ...staticGuiDefinitionPatterns,
136
+ ]),
137
+ ].sort((left, right) => compareCodeUnits(left, right));
138
+ }
139
+ function guiLocalisationPatterns(workspace, languages = ['l_english']) {
129
140
  const roots = workspace.registration.roots;
130
141
  const languageDirectories = [...new Set(languages)].map((language) => {
131
142
  if (!/^l_[a-z_]+$/u.test(language)) {
@@ -135,9 +146,6 @@ function guiDefinitionPatterns(workspace, languages = ['l_english']) {
135
146
  });
136
147
  return [
137
148
  ...new Set([
138
- ...under(roots.interface, ['**/*.gui', '**/*.gfx']),
139
- ...under(roots.gfx, ['**/*.gfx']),
140
- ...under(roots.scriptedGui, ['**/*.txt']),
141
149
  ...under(roots.localisation, [
142
150
  '*.{yml,yaml}',
143
151
  ...languageDirectories.flatMap((directory) => [
@@ -145,7 +153,6 @@ function guiDefinitionPatterns(workspace, languages = ['l_english']) {
145
153
  `${directory}/**/*.yaml`,
146
154
  ]),
147
155
  ]),
148
- ...staticGuiDefinitionPatterns,
149
156
  ]),
150
157
  ].sort((left, right) => compareCodeUnits(left, right));
151
158
  }
@@ -395,11 +402,18 @@ export class ScriptedGuiStudio {
395
402
  async scan(workspaceId, principal, signal, languages = ['l_english']) {
396
403
  signal?.throwIfAborted();
397
404
  const workspace = this.resolver.get(workspaceId, principal);
398
- const snapshot = await this.engine.scan(workspaceId, { patterns: guiDefinitionPatterns(workspace, languages) }, principal, signal);
405
+ const [snapshot, localisationFiles] = await Promise.all([
406
+ this.engine.scan(workspaceId, { patterns: guiDefinitionPatterns(workspace) }, principal, signal),
407
+ this.scanner.scan(workspace, {
408
+ patterns: guiLocalisationPatterns(workspace, languages),
409
+ ...(signal === undefined ? {} : { signal }),
410
+ }),
411
+ ]);
399
412
  signal?.throwIfAborted();
413
+ const files = mergeScannedFiles(snapshot.files, localisationFiles);
400
414
  return {
401
- files: snapshot.files,
402
- graph: buildGuiSourceGraph(snapshot.files, snapshot.index),
415
+ files,
416
+ graph: buildGuiSourceGraph(files, snapshot.index),
403
417
  };
404
418
  }
405
419
  async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english']) {
@@ -599,6 +613,7 @@ export class ScriptedGuiStudio {
599
613
  const stored = await this.artifacts.withAtomicChunkedWrites(workspace, writes, (artifacts) => Promise.resolve([...artifacts]), input.signal);
600
614
  return {
601
615
  artifacts: stored,
616
+ filesScanned: scanned.graph.filesScanned,
602
617
  render,
603
618
  stateScenes,
604
619
  resolutionScenes,