hoi4-agent-tools 2.8.0 → 2.8.2

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 (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/hoi4_agent_tools/core/artifacts.js +126 -4
  3. package/dist/hoi4_agent_tools/core/index.js +3 -1
  4. package/dist/hoi4_agent_tools/core/scanner.js +43 -2
  5. package/dist/hoi4_agent_tools/gui/assets.js +63 -14
  6. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +85 -14
  7. package/dist/hoi4_agent_tools/gui/layout.js +175 -24
  8. package/dist/hoi4_agent_tools/gui/source-graph.js +105 -6
  9. package/dist/hoi4_agent_tools/gui/studio.js +166 -34
  10. package/dist/hoi4_agent_tools/gui/validators.js +47 -27
  11. package/dist/hoi4_agent_tools/mcp/server/create.js +1 -1
  12. package/dist/hoi4_agent_tools/mcp/server/progress.js +60 -0
  13. package/dist/hoi4_agent_tools/mcp/tools/gui.js +15 -15
  14. package/dist/hoi4_agent_tools/version.js +1 -1
  15. package/docs/gui.md +6 -0
  16. package/package.json +1 -1
  17. package/schemas/configuration.schema.json +1 -1
  18. package/schemas/continuous-focus-palette.schema.json +1 -1
  19. package/schemas/custom-weighted-pool.schema.json +1 -1
  20. package/schemas/event-feature-manifest.schema.json +1 -1
  21. package/schemas/focus-plan.schema.json +1 -1
  22. package/schemas/focus-planning-sidecar.schema.json +1 -1
  23. package/schemas/gui-animation-source.schema.json +1 -1
  24. package/schemas/gui-helper.schema.json +1 -1
  25. package/schemas/gui-scenario.schema.json +1 -1
  26. package/schemas/map-operation.schema.json +1 -1
  27. package/schemas/operation-result.schema.json +1 -1
  28. package/schemas/probability-analysis-result.schema.json +1 -1
  29. package/schemas/probability-compare-input.schema.json +1 -1
  30. package/schemas/probability-evaluate-input.schema.json +1 -1
  31. package/schemas/probability-inspect-input.schema.json +1 -1
  32. package/schemas/probability-render-input.schema.json +1 -1
  33. package/schemas/probability-scenario-set.schema.json +1 -1
  34. package/schemas/probability-sequence-input.schema.json +1 -1
  35. package/schemas/probability-simulate-input.schema.json +1 -1
  36. package/schemas/probability-sweep-input.schema.json +1 -1
  37. package/server.json +2 -2
@@ -2,7 +2,7 @@ import sharp from 'sharp';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
5
- import { compareCodeUnits, canonicalJson, deterministicId, sha256Bytes, } from '../core/canonical.js';
5
+ import { compareCodeUnits, canonicalJson, deterministicId, hashCanonical, sha256Bytes, } from '../core/canonical.js';
6
6
  import { DiagnosticCollector } from '../core/diagnostic-collector.js';
7
7
  import { sortDiagnostics } from '../core/diagnostics.js';
8
8
  import { CoreEngine } from '../core/engine.js';
@@ -15,6 +15,7 @@ import { TRANSACTION_MAX_DIAGNOSTICS } from '../core/transaction-limits.js';
15
15
  import { PACKAGE_VERSION } from '../version.js';
16
16
  import { isSafeAnimationSourcePath } from './animation-manifest.js';
17
17
  import { GuiAssetCatalog, parseBmFont } from './assets.js';
18
+ import { projectGuiGraphForArtifact } from './inspection-artifact.js';
18
19
  import { buildGuiScene } from './layout.js';
19
20
  import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
20
21
  import { compareGuiImages, galleryDimensions, renderGallerySvg, renderGuiScene, } from './renderer.js';
@@ -174,6 +175,20 @@ function guiDefinitionPatterns(workspace) {
174
175
  ]),
175
176
  ].sort((left, right) => compareCodeUnits(left, right));
176
177
  }
178
+ function guiLayoutPatterns(workspace) {
179
+ return [...new Set(under(workspace.registration.roots.interface, ['**/*.gui']))].sort((left, right) => compareCodeUnits(left, right));
180
+ }
181
+ function guiLinkedDefinitionPatterns(workspace) {
182
+ const roots = workspace.registration.roots;
183
+ return [
184
+ ...new Set([
185
+ ...under(roots.interface, ['**/*.gfx']),
186
+ ...under(roots.gfx, ['**/*.gfx']),
187
+ ...under(roots.scriptedGui, ['**/*.txt']),
188
+ ...staticGuiDefinitionPatterns,
189
+ ]),
190
+ ].sort((left, right) => compareCodeUnits(left, right));
191
+ }
177
192
  function guiLocalisationPatterns(workspace, languages = ['l_english']) {
178
193
  const roots = workspace.registration.roots;
179
194
  const languageDirectories = [...new Set(languages)].map((language) => {
@@ -206,9 +221,17 @@ function referenceVariants(value) {
206
221
  if (normalized === undefined)
207
222
  return [];
208
223
  const extension = path.posix.extname(normalized);
224
+ const rasterExtensions = ['.png', '.bmp', '.tga', '.dds', '.svg'];
209
225
  const candidates = extension.length === 0
210
- ? ['.fnt', '.ttf', '.otf', '.woff', '.woff2', '.png', '.bmp', '.tga', '.dds', '.svg'].map((suffix) => `${normalized}${suffix}`)
211
- : [normalized];
226
+ ? ['.fnt', '.ttf', '.otf', '.woff', '.woff2', ...rasterExtensions].map((suffix) => `${normalized}${suffix}`)
227
+ : rasterExtensions.includes(extension.toLowerCase())
228
+ ? [
229
+ normalized,
230
+ ...rasterExtensions
231
+ .filter((candidate) => candidate !== extension.toLowerCase())
232
+ .map((candidate) => `${normalized.slice(0, -extension.length)}${candidate}`),
233
+ ]
234
+ : [normalized];
212
235
  return candidates;
213
236
  }
214
237
  function basenameFallbackPatterns(exactPaths, exactFiles) {
@@ -223,7 +246,35 @@ function basenameFallbackPatterns(exactPaths, exactFiles) {
223
246
  }
224
247
  function selectedElements(graph, windowName) {
225
248
  const byId = new Map(graph.elements.map((element) => [element.id, element]));
226
- const pending = graph.elements.filter(({ name }) => name === windowName).map(({ id }) => id);
249
+ const relatedScriptedGuiNames = new Set(graph.scriptedGuis
250
+ .filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)
251
+ .map(({ name }) => name));
252
+ let addedRelatedScriptedGui = true;
253
+ while (addedRelatedScriptedGui) {
254
+ addedRelatedScriptedGui = false;
255
+ for (const definition of graph.scriptedGuis) {
256
+ if (definition.parentScriptedGui === undefined ||
257
+ !relatedScriptedGuiNames.has(definition.parentScriptedGui) ||
258
+ relatedScriptedGuiNames.has(definition.name))
259
+ continue;
260
+ relatedScriptedGuiNames.add(definition.name);
261
+ addedRelatedScriptedGui = true;
262
+ }
263
+ }
264
+ const selectedRootNames = new Set([windowName]);
265
+ for (const scripted of graph.scriptedGuis.filter(({ name }) => relatedScriptedGuiNames.has(name))) {
266
+ if (scripted.windowName !== undefined)
267
+ selectedRootNames.add(scripted.windowName);
268
+ for (const dynamicList of scripted.dynamicListDefinitions) {
269
+ if (dynamicList.entryContainer !== undefined)
270
+ selectedRootNames.add(dynamicList.entryContainer);
271
+ if (dynamicList.countryScopeEntryContainer !== undefined)
272
+ selectedRootNames.add(dynamicList.countryScopeEntryContainer);
273
+ }
274
+ }
275
+ const pending = graph.elements
276
+ .filter(({ name }) => selectedRootNames.has(name))
277
+ .map(({ id }) => id);
227
278
  const selected = new Map();
228
279
  while (pending.length > 0) {
229
280
  const id = pending.pop();
@@ -276,8 +327,8 @@ function localisationIconSpriteCandidates(token) {
276
327
  const normalized = token.startsWith('GFX_') ? token : `GFX_${token}`;
277
328
  return [...new Set([normalized, `${normalized}_texticon`, `${normalized}_text_icon`])];
278
329
  }
279
- function referencedAssetPatternsForWindow(graph, windowName) {
280
- const spriteNames = new Set();
330
+ function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = []) {
331
+ const spriteNames = new Set(additionalSpriteNames);
281
332
  const fontNames = new Set();
282
333
  const textValues = new Set();
283
334
  for (const element of selectedElements(graph, windowName)) {
@@ -326,6 +377,15 @@ function referencedAssetPatternsForWindow(graph, windowName) {
326
377
  ];
327
378
  return [...new Set(references.flatMap(referenceVariants))].sort((left, right) => compareCodeUnits(left, right));
328
379
  }
380
+ function scenarioSpriteNames(scenarios) {
381
+ return [
382
+ ...new Set(scenarios.flatMap(({ scriptedGui }) => Object.entries(scriptedGui)
383
+ .filter(([key, value]) => typeof value === 'string' &&
384
+ value.startsWith('GFX_') &&
385
+ (key.endsWith('.image') || !key.includes('.')))
386
+ .map(([, value]) => value))),
387
+ ].sort((left, right) => compareCodeUnits(left, right));
388
+ }
329
389
  function bmFontPagePatterns(files) {
330
390
  const references = files.flatMap((file) => {
331
391
  if (!file.relativePath.toLowerCase().endsWith('.fnt') || file.shadowedBy !== undefined)
@@ -333,7 +393,9 @@ function bmFontPagePatterns(files) {
333
393
  const metrics = parseBmFont(file.bytes.toString('utf8'));
334
394
  if (metrics === undefined)
335
395
  return [];
336
- return metrics.pages.map((page) => path.posix.join(path.posix.dirname(file.relativePath), page));
396
+ const directory = path.posix.dirname(file.relativePath);
397
+ const sourceStem = file.relativePath.slice(0, -path.posix.extname(file.relativePath).length);
398
+ return [...metrics.pages.map((page) => path.posix.join(directory, page)), `${sourceStem}.dds`];
337
399
  });
338
400
  return [...new Set(references.flatMap(referenceVariants))].sort((left, right) => compareCodeUnits(left, right));
339
401
  }
@@ -343,14 +405,17 @@ function mergeScannedFiles(...groups) {
343
405
  byDisplayPath.set(file.displayPath, file);
344
406
  return [...byDisplayPath.values()].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
345
407
  }
346
- function withGraphDiagnostics(graph, validation) {
347
- const hard = graph.diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
408
+ function withGraphDiagnostics(graph, validation, selectedSourceIds = []) {
409
+ const graphDiagnostics = selectedSourceIds.length === 0
410
+ ? graph.diagnostics
411
+ : projectGuiGraphForArtifact(graph, selectedSourceIds).graph.diagnostics;
412
+ const hard = graphDiagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
348
413
  const diagnostics = new DiagnosticCollector(GUI_VALIDATION_MAX_DIAGNOSTICS, {
349
414
  code: 'GUI_VALIDATION_DIAGNOSTICS_TRUNCATED',
350
415
  category: 'layout',
351
416
  message: 'Combined GUI source and validation diagnostics exceeded the fixed result ceiling',
352
417
  });
353
- diagnostics.pushMany(graph.diagnostics);
418
+ diagnostics.pushMany(graphDiagnostics);
354
419
  diagnostics.pushMany(validation.diagnostics);
355
420
  return {
356
421
  diagnostics: sortDiagnostics(diagnostics.values()),
@@ -517,6 +582,7 @@ export class ScriptedGuiStudio {
517
582
  transactions;
518
583
  scanner;
519
584
  artifacts;
585
+ #graphCache = new Map();
520
586
  constructor(engineOrResolver, transactions, scanner = new WorkspaceScanner(), artifacts = new ArtifactStore()) {
521
587
  this.engine =
522
588
  engineOrResolver instanceof CoreEngine
@@ -531,6 +597,32 @@ export class ScriptedGuiStudio {
531
597
  this.scanner = this.engine.scanner;
532
598
  this.artifacts = this.engine.artifacts;
533
599
  }
600
+ graphForFiles(files, scope) {
601
+ const key = `${scope}:${hashCanonical(files.map(({ displayPath, sha256, shadowedBy }) => ({
602
+ displayPath,
603
+ sha256,
604
+ ...(shadowedBy === undefined ? {} : { shadowedBy }),
605
+ })))}`;
606
+ const cached = this.#graphCache.get(key);
607
+ if (cached !== undefined) {
608
+ this.#graphCache.delete(key);
609
+ this.#graphCache.set(key, cached);
610
+ return cached;
611
+ }
612
+ const retainedFiles = [...files];
613
+ const scanned = {
614
+ files: retainedFiles,
615
+ graph: buildGuiSourceGraph(retainedFiles, this.engine.indexFiles(retainedFiles)),
616
+ };
617
+ this.#graphCache.set(key, scanned);
618
+ while (this.#graphCache.size > 8) {
619
+ const oldest = this.#graphCache.keys().next().value;
620
+ if (oldest === undefined)
621
+ break;
622
+ this.#graphCache.delete(oldest);
623
+ }
624
+ return scanned;
625
+ }
534
626
  async scan(workspaceId, principal, signal, languages = ['l_english']) {
535
627
  signal?.throwIfAborted();
536
628
  const workspace = this.resolver.get(workspaceId, principal);
@@ -543,16 +635,36 @@ export class ScriptedGuiStudio {
543
635
  ]);
544
636
  signal?.throwIfAborted();
545
637
  const files = mergeScannedFiles(snapshot.files, localisationFiles);
546
- return {
547
- files,
548
- graph: buildGuiSourceGraph(files, snapshot.index),
549
- };
638
+ return this.graphForFiles(files, `workspace:${workspaceId}`);
550
639
  }
551
- async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english']) {
552
- const definitions = await this.scan(workspaceId, principal, signal, languages);
553
- assertTargetWindowAvailable(definitions.graph, windowName);
640
+ async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = []) {
554
641
  const workspace = this.resolver.get(workspaceId, principal);
555
- const referencedPatterns = referencedAssetPatternsForWindow(definitions.graph, windowName);
642
+ const layoutPatterns = guiLayoutPatterns(workspace);
643
+ const overlayLayouts = await this.engine.scan(workspaceId, {
644
+ patterns: layoutPatterns,
645
+ rootKinds: ['mod', 'dependency', 'fixture'],
646
+ }, principal, signal);
647
+ let layoutFiles = overlayLayouts.files;
648
+ let layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
649
+ if (!layoutGraph.elements.some(({ name }) => name === windowName)) {
650
+ const gameLayouts = await this.engine.scan(workspaceId, { patterns: layoutPatterns, rootKinds: ['game'] }, principal, signal);
651
+ const gameGraph = this.graphForFiles(gameLayouts.files, `game-layout:${workspaceId}`).graph;
652
+ const selectedPaths = new Set(gameGraph.elements
653
+ .filter(({ name }) => name === windowName)
654
+ .map(({ sourcePath }) => sourcePath));
655
+ layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.files.filter(({ displayPath }) => selectedPaths.has(displayPath)));
656
+ layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
657
+ }
658
+ if (!layoutGraph.elements.some(({ name }) => name === windowName))
659
+ assertTargetWindowAvailable(layoutGraph, windowName);
660
+ const [linkedDefinitions, localisation] = await Promise.all([
661
+ this.engine.scan(workspaceId, { patterns: guiLinkedDefinitionPatterns(workspace) }, principal, signal),
662
+ this.engine.scan(workspaceId, { patterns: guiLocalisationPatterns(workspace, languages) }, principal, signal),
663
+ ]);
664
+ const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions.files, localisation.files);
665
+ const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`);
666
+ assertTargetWindowAvailable(definitions.graph, windowName);
667
+ const referencedPatterns = referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames);
556
668
  const exactReferenced = referencedPatterns.length === 0
557
669
  ? []
558
670
  : await this.scanner.scan(workspace, {
@@ -586,23 +698,24 @@ export class ScriptedGuiStudio {
586
698
  const fontPages = mergeScannedFiles(exactFontPages, fallbackFontPages);
587
699
  signal?.throwIfAborted();
588
700
  const files = mergeScannedFiles(definitions.files, referenced, fontPages);
589
- return { files, graph: buildGuiSourceGraph(files, this.engine.indexFiles(files)) };
701
+ return this.graphForFiles(files, `window:${workspaceId}:${windowName}`);
590
702
  }
591
703
  async lint(input) {
592
704
  input.signal?.throwIfAborted();
593
705
  const scenario = parsePreviewScenario(input.scenario);
594
706
  const relatedScenarios = (input.relatedScenarios ?? []).map(parsePreviewScenario);
595
707
  assertUniqueScenarioIds([scenario, ...relatedScenarios]);
596
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [scenario.language, ...relatedScenarios.map(({ language }) => language)]);
708
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [scenario.language, ...relatedScenarios.map(({ language }) => language)], scenarioSpriteNames([scenario, ...relatedScenarios]));
597
709
  const budget = new RenderBudget();
598
- const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget);
710
+ const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
599
711
  const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
600
712
  const relatedScenes = [];
601
713
  for (const related of relatedScenarios) {
602
714
  input.signal?.throwIfAborted();
603
715
  relatedScenes.push(await buildGuiScene(scanned.graph, scanned.files, input.windowName, related, catalog));
604
716
  }
605
- const validation = withGraphDiagnostics(scanned.graph, await validateGuiScene(scanned.graph, scene, scanned.files, relatedScenes, catalog));
717
+ const validationGraph = projectGuiGraphForArtifact(scanned.graph, [scene, ...relatedScenes].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId))).graph;
718
+ const validation = withGraphDiagnostics(validationGraph, await validateGuiScene(validationGraph, scene, scanned.files, relatedScenes, catalog));
606
719
  return { scene, graph: scanned.graph, validation };
607
720
  }
608
721
  async compare(input) {
@@ -610,10 +723,11 @@ export class ScriptedGuiStudio {
610
723
  const budget = new RenderBudget();
611
724
  const beforeScenario = parsePreviewScenario(input.before);
612
725
  const afterScenario = parsePreviewScenario(input.after);
613
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language]);
614
- const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget);
615
- const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, catalog);
616
- const after = await buildGuiScene(scanned.graph, scanned.files, input.windowName, afterScenario, catalog);
726
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]));
727
+ const beforeCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, beforeScenario.language);
728
+ const afterCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, afterScenario.language);
729
+ const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, beforeCatalog);
730
+ const after = await buildGuiScene(scanned.graph, scanned.files, input.windowName, afterScenario, afterCatalog);
617
731
  const [beforeRender, afterRender] = await Promise.all([
618
732
  renderGuiScene(before, ['full'], input.signal, budget),
619
733
  renderGuiScene(after, ['full'], input.signal, budget),
@@ -650,8 +764,8 @@ export class ScriptedGuiStudio {
650
764
  scenario.language,
651
765
  baselineScenario.language,
652
766
  ...relatedScenarios.map(({ language }) => language),
653
- ]);
654
- const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget);
767
+ ], scenarioSpriteNames([scenario, baselineScenario, ...relatedScenarios]));
768
+ const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
655
769
  const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
656
770
  const render = await renderGuiScene(scene, undefined, input.signal, budget);
657
771
  const scenarioScenes = [scene];
@@ -715,12 +829,19 @@ export class ScriptedGuiStudio {
715
829
  const baselineScene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, baselineScenario, catalog);
716
830
  const baselineRender = await renderGuiScene(baselineScene, ['full'], input.signal, budget);
717
831
  const comparison = await compareGuiImages(fullImage(baselineRender).png, fullImage(render).png, budget, input.signal);
718
- const validation = await validateGuiScene(scanned.graph, scene, scanned.files, scenarioScenes.slice(1), catalog);
832
+ const renderedSourceIds = [
833
+ ...scenarioScenes,
834
+ ...stateScenes,
835
+ ...resolutionScenes,
836
+ baselineScene,
837
+ ].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId));
838
+ const validationGraph = projectGuiGraphForArtifact(scanned.graph, renderedSourceIds).graph;
839
+ const validation = await validateGuiScene(validationGraph, scene, scanned.files, scenarioScenes.slice(1), catalog);
719
840
  const stateValidation = validateStateMatrix(stateScenes);
720
841
  const resolutionValidation = validateResolutionDrift(resolutionScenes);
721
842
  validation.diagnostics.push(...stateValidation.diagnostics, ...resolutionValidation.diagnostics);
722
843
  validation.checks.push(...stateValidation.checks, ...resolutionValidation.checks);
723
- const completeValidation = withGraphDiagnostics(scanned.graph, validation);
844
+ const completeValidation = withGraphDiagnostics(validationGraph, validation);
724
845
  const scenarioGallerySvg = renderGallerySvg(`${input.windowName} scripted scenario matrix`, scenarioItems);
725
846
  const stateGallerySvg = renderGallerySvg(`${input.windowName} state matrix`, stateItems);
726
847
  const resolutionGallerySvg = renderGallerySvg(`${input.windowName} resolution and UI-scale matrix`, resolutionItems);
@@ -746,6 +867,9 @@ export class ScriptedGuiStudio {
746
867
  .toBuffer(),
747
868
  ]);
748
869
  const slug = safeSlug(input.windowName);
870
+ const projectedArtifactGraph = projectGuiGraphForArtifact(scanned.graph, [
871
+ ...new Set([...scenarioScenes, ...stateScenes, ...resolutionScenes, baselineScene].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId))),
872
+ ]);
749
873
  const writes = [];
750
874
  const add = (name, mimeType, content, kind, scenarioScenes = [scene], metadata = {}) => {
751
875
  input.signal?.throwIfAborted();
@@ -764,7 +888,13 @@ export class ScriptedGuiStudio {
764
888
  add(`${slug}-layout.json`, 'application/json', render.layoutJson, 'gui-layout-json');
765
889
  add(`${slug}-scenario.json`, 'application/json', render.scenarioJson, 'gui-preview-scenario');
766
890
  add(`${slug}-fidelity.json`, 'application/json', `${canonicalJson({ offline: true, fidelity: render.fidelity })}\n`, 'gui-fidelity-report');
767
- add(`${slug}-source-graph.json`, 'application/json', `${canonicalJson(scanned.graph)}\n`, 'gui-source-graph');
891
+ add(`${slug}-source-graph.json`, 'application/json', `${canonicalJson({
892
+ schemaVersion: 1,
893
+ graph: projectedArtifactGraph.graph,
894
+ ...(projectedArtifactGraph.projection === undefined
895
+ ? {}
896
+ : { graphProjection: projectedArtifactGraph.projection }),
897
+ })}\n`, 'gui-source-graph');
768
898
  add(`${slug}-validation.json`, 'application/json', `${canonicalJson(completeValidation)}\n`, 'gui-validation-report', [...scenarioScenes, ...stateScenes, ...resolutionScenes]);
769
899
  add(`${slug}-scenario-matrix.svg`, 'image/svg+xml', scenarioGallerySvg, 'gui-scripted-scenario-matrix', scenarioScenes);
770
900
  add(`${slug}-scenario-matrix.png`, 'image/png', scenarioGalleryPng, 'gui-scripted-scenario-matrix', scenarioScenes);
@@ -910,9 +1040,10 @@ export class ScriptedGuiStudio {
910
1040
  });
911
1041
  if ((input.windowName === undefined) !== (input.scenario === undefined))
912
1042
  throw new Error('GUI visual preflight requires both windowName and scenario.');
1043
+ const previewScenario = input.scenario === undefined ? undefined : parsePreviewScenario(input.scenario);
913
1044
  const initiallyScanned = input.windowName === undefined
914
1045
  ? await this.scan(input.workspaceId, input.principal, input.signal)
915
- : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [parsePreviewScenario(input.scenario).language]);
1046
+ : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]));
916
1047
  const exactTargets = await this.scanner.scan(workspace, {
917
1048
  patterns: prepared.map(({ relativePath }) => relativePath),
918
1049
  ...(input.signal === undefined ? {} : { signal: input.signal }),
@@ -970,8 +1101,9 @@ export class ScriptedGuiStudio {
970
1101
  const proposedScene = await buildGuiScene(proposedGraph, proposedFiles, input.windowName, scenario, proposedCatalog);
971
1102
  const proposedRender = await renderGuiScene(proposedScene, ['full'], input.signal, renderBudget);
972
1103
  const proposedImage = fullImage(proposedRender);
973
- const proposedValidation = await validateGuiScene(proposedGraph, proposedScene, proposedFiles, [], proposedCatalog);
974
- preflightDiagnostics.push(...proposedGraph.diagnostics, ...proposedScene.diagnostics, ...proposedValidation.diagnostics);
1104
+ const proposedValidationGraph = projectGuiGraphForArtifact(proposedGraph, proposedScene.elements.map(({ sourceId }) => sourceId)).graph;
1105
+ const proposedValidation = await validateGuiScene(proposedValidationGraph, proposedScene, proposedFiles, [], proposedCatalog);
1106
+ preflightDiagnostics.push(...proposedValidationGraph.diagnostics, ...proposedScene.diagnostics, ...proposedValidation.diagnostics);
975
1107
  const visualPassed = proposedValidation.checks.every(({ passed }) => passed) &&
976
1108
  !preflightDiagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker');
977
1109
  preflightChecks.push(...proposedValidation.checks, {
@@ -301,8 +301,23 @@ function actionElementName(action) {
301
301
  .replace(/_(?:visible|enabled)$/u, '');
302
302
  }
303
303
  function attachedScriptedGuis(graph, scene) {
304
- return graph.scriptedGuis.filter((definition) => definition.windowName === scene.windowName ||
305
- definition.parentWindowName === scene.windowName);
304
+ const attachedNames = new Set(graph.scriptedGuis
305
+ .filter((definition) => definition.windowName === scene.windowName ||
306
+ definition.parentWindowName === scene.windowName)
307
+ .map(({ name }) => name));
308
+ let added = true;
309
+ while (added) {
310
+ added = false;
311
+ for (const definition of graph.scriptedGuis) {
312
+ if (definition.parentScriptedGui === undefined ||
313
+ !attachedNames.has(definition.parentScriptedGui) ||
314
+ attachedNames.has(definition.name))
315
+ continue;
316
+ attachedNames.add(definition.name);
317
+ added = true;
318
+ }
319
+ }
320
+ return graph.scriptedGuis.filter(({ name }) => attachedNames.has(name));
306
321
  }
307
322
  function normalizedCostResource(value) {
308
323
  const normalized = value.toLowerCase().replace(/_icon$/u, '');
@@ -410,8 +425,6 @@ function validateOverlapAndGeometry(scene, diagnostics, work) {
410
425
  if (element.clickable && element.visible && !sameRect(element.rect, element.unclippedRect)) {
411
426
  diagnostics.push(issue('GUI_CLICK_BOUNDS_MISMATCH', 'warning', 'design', `${element.name} has visible/clipped bounds that differ from its source click rectangle.`, element));
412
427
  }
413
- if (element.clickable && !element.visible)
414
- diagnostics.push(issue('GUI_INVISIBLE_CLICK_BLOCKER', 'error', 'design', `${element.name} remains clickable while invisible in this scenario.`, element));
415
428
  }
416
429
  const visiblePairCount = (visible.length * (visible.length - 1)) / 2;
417
430
  if (work.admitPairs(visiblePairCount, 'GUI overlap validation', diagnostics)) {
@@ -547,20 +560,16 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
547
560
  diagnostics.push(issue('GUI_RENDER_FIELD_UNSUPPORTED', 'info', 'rendering', `${element.name}.${field} is preserved in source but not reliably modelled.`, element));
548
561
  }
549
562
  const validContexts = new Set([
550
- 'country',
551
- 'state',
552
- 'unit_leader',
553
- 'operative',
554
- 'character',
555
- 'division',
563
+ 'player_context',
564
+ 'selected_country_context',
565
+ 'selected_state_context',
556
566
  'decision',
557
567
  'decision_category',
558
- 'national_focus',
559
- 'shared_focus',
560
- 'technology',
561
- 'equipment',
562
- 'military_industrial_organization',
563
- 'special_project',
568
+ 'diplomatic_action',
569
+ 'diplomacy_target_context',
570
+ 'national_focus_context',
571
+ 'country_mapicon',
572
+ 'state_mapicon',
564
573
  ]);
565
574
  const names = new Set(graph.scriptedGuis.map(({ name }) => name));
566
575
  const windowNames = new Set(graph.elements.map(({ name }) => name));
@@ -576,10 +585,10 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
576
585
  }
577
586
  else if (!validContexts.has(scripted.contextType)) {
578
587
  diagnostics.push({
579
- code: 'GUI_SCRIPTED_CONTEXT_INVALID',
580
- severity: 'error',
588
+ code: 'GUI_SCRIPTED_CONTEXT_UNKNOWN',
589
+ severity: 'warning',
581
590
  category: 'reference',
582
- message: `Scripted GUI ${scripted.name} uses unknown context_type ${scripted.contextType}.`,
591
+ message: `Scripted GUI ${scripted.name} uses context_type ${scripted.contextType}, which is not in the renderer's installed-game and documented context inventory.`,
583
592
  ...(scripted.location === undefined ? {} : { location: scripted.location }),
584
593
  });
585
594
  }
@@ -612,10 +621,20 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
612
621
  if (attached.length > 0) {
613
622
  const effects = new Set(attached.flatMap(({ effects }) => effects.map(actionElementName)));
614
623
  const triggers = new Set(attached.flatMap(({ triggers }) => triggers.map(actionElementName)));
624
+ const elementsById = new Map(graph.elements.map((element) => [element.id, element]));
615
625
  for (const button of scene.elements.filter(({ elementType }) => /button/iu.test(elementType))) {
616
- if (!effects.has(button.name))
617
- diagnostics.push(issue('GUI_BUTTON_EFFECT_MISSING', 'error', 'reference', `Button ${button.name} has no matching scripted GUI click effect.`, button));
618
- if (!triggers.has(button.name))
626
+ const source = elementsById.get(button.sourceId);
627
+ const tooltipOnly = !effects.has(button.name) &&
628
+ !triggers.has(button.name) &&
629
+ (typeof source?.attributes.pdx_tooltip === 'string' ||
630
+ typeof source?.attributes.pdx_tooltip_delayed === 'string') &&
631
+ source.attributes.text === undefined &&
632
+ source.attributes.buttonText === undefined;
633
+ if (!effects.has(button.name) && !triggers.has(button.name) && !tooltipOnly)
634
+ diagnostics.push(issue('GUI_BUTTON_EFFECT_MISSING', 'error', 'reference', `Button ${button.name} has no matching scripted GUI click effect or tooltip-only role.`, button));
635
+ if (triggers.has(button.name) && !effects.has(button.name))
636
+ diagnostics.push(issue('GUI_BUTTON_EFFECT_MISSING', 'warning', 'reference', `Button ${button.name} is controlled by a scripted GUI trigger but has no matching click effect.`, button));
637
+ if (effects.has(button.name) && !triggers.has(button.name))
619
638
  diagnostics.push(issue('GUI_BUTTON_TRIGGER_MISSING', 'warning', 'reference', `Button ${button.name} has no matching scripted GUI visibility/enabled trigger.`, button));
620
639
  }
621
640
  for (const scripted of attached.filter(({ effects, aiEnabled }) => effects.length > 0 && !aiEnabled)) {
@@ -1036,13 +1055,15 @@ function validationResult(diagnostics, codes) {
1036
1055
  return {
1037
1056
  diagnostics: sorted,
1038
1057
  checks: codes.map((code) => {
1039
- const count = sorted.filter((diagnostic) => diagnostic.code === code).length;
1058
+ const matching = sorted.filter((diagnostic) => diagnostic.code === code);
1059
+ const count = matching.length;
1060
+ const blockingCount = matching.filter(({ severity }) => severity === 'error' || severity === 'blocker').length;
1040
1061
  return {
1041
1062
  id: code.toLowerCase(),
1042
- passed: count === 0,
1063
+ passed: blockingCount === 0,
1043
1064
  message: count === 0
1044
1065
  ? `${code} not detected.`
1045
- : `${code} detected ${count} time${count === 1 ? '' : 's'}.`,
1066
+ : `${code} detected ${count} time${count === 1 ? '' : 's'}${blockingCount === 0 ? ' as non-blocking findings' : `, including ${blockingCount} blocking finding${blockingCount === 1 ? '' : 's'}`}.`,
1046
1067
  };
1047
1068
  }),
1048
1069
  };
@@ -1112,7 +1133,6 @@ export async function validateGuiScene(graph, scene, files, relatedScenes = [],
1112
1133
  'GUI_INVALID_SIZE',
1113
1134
  'GUI_CHILD_OUTSIDE_CLIPPED_PARENT',
1114
1135
  'GUI_CLICK_BOUNDS_MISMATCH',
1115
- 'GUI_INVISIBLE_CLICK_BLOCKER',
1116
1136
  'GUI_CONFLICTING_CLICK_REGIONS',
1117
1137
  'GUI_MISSING_SPRITE',
1118
1138
  'GUI_MISSING_TEXTURE',
@@ -1122,7 +1142,7 @@ export async function validateGuiScene(graph, scene, files, relatedScenes = [],
1122
1142
  'GUI_ANIMATION_SHEET_DIMENSION_INVALID',
1123
1143
  'GUI_ANIMATION_STATIC_FALLBACK_MISSING',
1124
1144
  'GUI_PARENT_WINDOW_INVALID',
1125
- 'GUI_SCRIPTED_CONTEXT_INVALID',
1145
+ 'GUI_SCRIPTED_CONTEXT_UNKNOWN',
1126
1146
  'GUI_Z_ORDER_RISK',
1127
1147
  'GUI_SCROLL_ROW_CUTOFF',
1128
1148
  'GUI_RESOLUTION_DRIFT',
@@ -8,7 +8,7 @@ import { registerEventTools } from '../tools/event.js';
8
8
  import { registerTechnologyTools } from '../tools/technology.js';
9
9
  import { registerProbabilityTools } from '../tools/probability.js';
10
10
  import { registerMcpResources } from '../resources/register.js';
11
- export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Large evidence is linked as resources.';
11
+ export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Long GUI calls emit periodic progress heartbeats when the client requests progress. Large evidence is linked as resources.';
12
12
  export function createMcpServer(engine, context = {}) {
13
13
  const server = new McpServer({
14
14
  name: PACKAGE_NAME,
@@ -1,9 +1,18 @@
1
+ /**
2
+ * Interval used for long-running MCP operations. It is shorter than the request timeout used by
3
+ * common MCP clients, while still keeping notification traffic negligible for ordinary calls.
4
+ */
5
+ export const PROGRESS_HEARTBEAT_INTERVAL_MS = 10_000;
1
6
  export function progressReporter(extra) {
2
7
  let latest = Number.NEGATIVE_INFINITY;
8
+ let latestTotal = 1;
9
+ let latestMessage = '';
3
10
  return {
4
11
  signal: extra.signal,
5
12
  async report(progress, total, message) {
6
13
  extra.signal.throwIfAborted();
14
+ latestTotal = total;
15
+ latestMessage = message;
7
16
  const normalized = Math.max(latest, Math.min(progress, total));
8
17
  const progressToken = extra._meta?.progressToken;
9
18
  if (progressToken === undefined)
@@ -16,5 +25,56 @@ export function progressReporter(extra) {
16
25
  params: { progressToken, progress: normalized, total, message },
17
26
  });
18
27
  },
28
+ async pulse(message = latestMessage) {
29
+ extra.signal.throwIfAborted();
30
+ const progressToken = extra._meta?.progressToken;
31
+ if (progressToken === undefined)
32
+ return;
33
+ await extra.sendNotification({
34
+ method: 'notifications/progress',
35
+ params: {
36
+ progressToken,
37
+ progress: Number.isFinite(latest) ? latest : 0,
38
+ total: latestTotal,
39
+ message,
40
+ },
41
+ });
42
+ },
43
+ };
44
+ }
45
+ /**
46
+ * Keep an MCP request alive while a domain service is in a long, opaque asynchronous stage.
47
+ * Progress notifications are best-effort: a disconnected client must not turn a successful
48
+ * server operation into a partial result, while the request's AbortSignal still cancels work.
49
+ */
50
+ export async function withProgressHeartbeat(operation, reporter, message, intervalMs = PROGRESS_HEARTBEAT_INTERVAL_MS) {
51
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
52
+ throw new RangeError('Progress heartbeat interval must be a positive finite number');
53
+ }
54
+ reporter.signal.throwIfAborted();
55
+ let active = true;
56
+ let inFlight = false;
57
+ const tick = () => {
58
+ if (!active || inFlight || reporter.signal.aborted)
59
+ return;
60
+ inFlight = true;
61
+ void reporter
62
+ .pulse(message)
63
+ .catch(() => undefined)
64
+ .finally(() => {
65
+ inFlight = false;
66
+ });
19
67
  };
68
+ const timer = setInterval(tick, intervalMs);
69
+ timer.unref();
70
+ // Tell the client which opaque stage has started immediately. Waiting for the first interval
71
+ // leaves short-but-expensive stages silent and makes keep-alive behavior scheduler-dependent.
72
+ tick();
73
+ try {
74
+ return await (typeof operation === 'function' ? operation() : operation);
75
+ }
76
+ finally {
77
+ active = false;
78
+ clearInterval(timer);
79
+ }
20
80
  }