hoi4-agent-tools 3.3.0 → 3.4.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 (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/hoi4_agent_tools/core/condition-evaluator.js +13 -8
  3. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  4. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  5. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  6. package/dist/hoi4_agent_tools/event/operations.js +16 -0
  7. package/dist/hoi4_agent_tools/event/service.js +21 -2
  8. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  9. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  10. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  11. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  12. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  13. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  14. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  15. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  16. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  17. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  18. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  19. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  20. package/dist/hoi4_agent_tools/gui/validators.js +38 -12
  21. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  22. package/dist/hoi4_agent_tools/map/render.js +70 -1
  23. package/dist/hoi4_agent_tools/map/service.js +30 -6
  24. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  25. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  26. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  27. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  28. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  29. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  30. package/dist/hoi4_agent_tools/probability/evaluation.js +3 -0
  31. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  32. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  33. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  34. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  35. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  36. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  37. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  38. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  39. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  40. package/dist/hoi4_agent_tools/technology/render.js +286 -31
  41. package/dist/hoi4_agent_tools/technology/service.js +55 -8
  42. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  43. package/dist/hoi4_agent_tools/version.js +1 -1
  44. package/docs/events.md +3 -1
  45. package/docs/focus.md +10 -0
  46. package/docs/gui.md +49 -3
  47. package/docs/map.md +8 -0
  48. package/docs/probability.md +3 -1
  49. package/docs/technology.md +7 -2
  50. package/package.json +1 -1
  51. package/schemas/configuration.schema.json +1 -1
  52. package/schemas/continuous-focus-palette.schema.json +1 -1
  53. package/schemas/custom-weighted-pool.schema.json +1 -1
  54. package/schemas/decision-inspect-input.schema.json +1 -1
  55. package/schemas/event-feature-manifest.schema.json +1 -1
  56. package/schemas/focus-plan.schema.json +1 -1
  57. package/schemas/focus-planning-sidecar.schema.json +1 -1
  58. package/schemas/gui-animation-source.schema.json +1 -1
  59. package/schemas/gui-helper.schema.json +1 -1
  60. package/schemas/gui-scenario.schema.json +1 -1
  61. package/schemas/helper-expansion-request.schema.json +1 -1
  62. package/schemas/helper-expansion-summary.schema.json +1 -1
  63. package/schemas/impact-inspect-input.schema.json +1 -1
  64. package/schemas/map-operation.schema.json +1 -1
  65. package/schemas/mechanic-test-input.schema.json +1 -1
  66. package/schemas/operation-result.schema.json +1 -1
  67. package/schemas/package-check-input.schema.json +1 -1
  68. package/schemas/package-manifest.schema.json +1 -1
  69. package/schemas/probability-analysis-result.schema.json +1 -1
  70. package/schemas/probability-compare-input.schema.json +1 -1
  71. package/schemas/probability-evaluate-input.schema.json +1 -1
  72. package/schemas/probability-inspect-input.schema.json +1 -1
  73. package/schemas/probability-render-input.schema.json +1 -1
  74. package/schemas/probability-scenario-set.schema.json +1 -1
  75. package/schemas/probability-sequence-input.schema.json +1 -1
  76. package/schemas/probability-simulate-input.schema.json +1 -1
  77. package/schemas/probability-sweep-input.schema.json +1 -1
  78. package/schemas/scenario-suite.schema.json +1 -1
  79. package/schemas/scenario-test-input.schema.json +1 -1
  80. package/schemas/shared-scenario.schema.json +1 -1
  81. package/server.json +2 -2
@@ -26,6 +26,7 @@ import { buildGuiSourceGraph } from './source-graph.js';
26
26
  import { assertGuiSourcePatchesSafe } from './source-patch.js';
27
27
  import { validateGuiScene, validateResolutionDrift, validateStateMatrix } from './validators.js';
28
28
  const staticGuiDefinitionPatterns = [
29
+ 'common/focus_inlay_windows/**/*.txt',
29
30
  'common/scripted_triggers/**/*.txt',
30
31
  'common/script_constants/**/*.txt',
31
32
  'common/scripted_localisation/**/*.txt',
@@ -248,8 +249,14 @@ function basenameFallbackPatterns(exactPaths, exactFiles) {
248
249
  .map((candidate) => `**/${path.posix.basename(candidate)}`)),
249
250
  ].sort((left, right) => compareCodeUnits(left, right));
250
251
  }
251
- function selectedElements(graph, windowName) {
252
+ function selectedElements(graph, windowName, additionalTemplateNames = []) {
252
253
  const byId = new Map(graph.elements.map((element) => [element.id, element]));
254
+ const byName = new Map();
255
+ for (const element of graph.elements) {
256
+ const definitions = byName.get(element.name) ?? [];
257
+ definitions.push(element);
258
+ byName.set(element.name, definitions);
259
+ }
253
260
  const relatedScriptedGuiNames = new Set(graph.scriptedGuis
254
261
  .filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)
255
262
  .map(({ name }) => name));
@@ -265,7 +272,7 @@ function selectedElements(graph, windowName) {
265
272
  addedRelatedScriptedGui = true;
266
273
  }
267
274
  }
268
- const selectedRootNames = new Set([windowName]);
275
+ const selectedRootNames = new Set([windowName, ...additionalTemplateNames]);
269
276
  for (const scripted of graph.scriptedGuis.filter(({ name }) => relatedScriptedGuiNames.has(name))) {
270
277
  if (scripted.windowName !== undefined)
271
278
  selectedRootNames.add(scripted.windowName);
@@ -289,6 +296,11 @@ function selectedElements(graph, windowName) {
289
296
  continue;
290
297
  selected.set(id, element);
291
298
  pending.push(...element.childIds);
299
+ const referenced = new Set();
300
+ collectNamedAttributes(element.attributes, new Set(['verticalScrollbar', 'horizontalScrollbar', 'scrollbarType', 'scrollbartype']), referenced);
301
+ for (const name of referenced)
302
+ for (const candidate of byName.get(name) ?? [])
303
+ pending.push(candidate.id);
292
304
  }
293
305
  return [...selected.values()];
294
306
  }
@@ -331,14 +343,14 @@ function localisationIconSpriteCandidates(token) {
331
343
  const normalized = token.startsWith('GFX_') ? token : `GFX_${token}`;
332
344
  return [...new Set([normalized, `${normalized}_texticon`, `${normalized}_text_icon`])];
333
345
  }
334
- export function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = [], languages = ['l_english']) {
346
+ export function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = [], languages = ['l_english'], additionalTemplateNames = []) {
335
347
  const spriteNames = new Set(additionalSpriteNames);
336
348
  const fontNames = new Set();
337
349
  const textValues = new Set();
338
- for (const element of selectedElements(graph, windowName)) {
350
+ for (const element of selectedElements(graph, windowName, additionalTemplateNames)) {
339
351
  collectNamedAttributes(element.attributes, new Set(['spriteType', 'quadTextureSprite']), spriteNames);
340
352
  collectNamedAttributes(element.attributes, new Set(['font', 'buttonFont']), fontNames);
341
- collectNamedAttributes(element.attributes, new Set(['text', 'buttonText']), textValues);
353
+ collectNamedAttributes(element.attributes, new Set(['text', 'buttonText', 'context_aware_text']), textValues);
342
354
  }
343
355
  for (const localisation of graph.localisation) {
344
356
  if (textValues.has(localisation.key))
@@ -391,6 +403,10 @@ export function referencedAssetPatternsForWindow(graph, windowName, additionalSp
391
403
  const match = /^\[([^\]]+)\]$/u.exec(image);
392
404
  return match?.[1] === undefined ? [] : [match[1]];
393
405
  }));
406
+ for (const gui of graph.scriptedGuis.filter(({ name }) => relatedScriptedGuiNames.has(name)))
407
+ for (const image of gui.imageDefinitions ?? [])
408
+ for (const choice of image.choices)
409
+ spriteNames.add(choice.spriteName);
394
410
  const usesCountryFlags = graph.scriptedGuis
395
411
  .filter(({ name }) => relatedScriptedGuiNames.has(name))
396
412
  .flatMap(({ propertyDefinitions }) => propertyDefinitions)
@@ -434,7 +450,7 @@ export function referencedAssetPatternsForWindow(graph, windowName, additionalSp
434
450
  normalizedLanguages.has(normalized.replace(/^l_/u, '')));
435
451
  })));
436
452
  const references = [
437
- ...[...selectedSprites.values()].flatMap(({ texturePath, texturePath2 }) => [texturePath, texturePath2].filter((value) => value !== undefined)),
453
+ ...[...selectedSprites.values()].flatMap(({ texturePath, texturePath2, effectFile }) => [texturePath, texturePath2, effectFile, effectFile?.replace(/\.lua$/iu, '.shader')].filter((value) => value !== undefined)),
438
454
  ...selectedFonts.flatMap(({ assetPaths }) => assetPaths),
439
455
  ...selectedManifests.flatMap((manifest) => [
440
456
  manifest.sheet.path,
@@ -477,6 +493,35 @@ function scenarioSpriteNames(scenarios, additionalText = []) {
477
493
  }
478
494
  return [...sprites].sort((left, right) => compareCodeUnits(left, right));
479
495
  }
496
+ function scenarioTemplateNames(scenarios) {
497
+ return [
498
+ ...new Set(scenarios.flatMap(({ lists }) => Object.values(lists).flatMap((rows) => rows.flatMap(({ entryContainer }) => typeof entryContainer === 'string' && entryContainer.length > 0 ? [entryContainer] : [])))),
499
+ ].sort(compareCodeUnits);
500
+ }
501
+ /** Native row bindings admit only bounded country tokens, never caller-supplied paths or globs. */
502
+ export function scenarioCountryFlagPatterns(scenarios) {
503
+ const tags = new Set();
504
+ for (const scenario of scenarios) {
505
+ for (const values of [
506
+ scenario.values,
507
+ scenario.scriptedGui,
508
+ ...Object.values(scenario.lists).flat(),
509
+ ]) {
510
+ for (const [key, value] of Object.entries(values)) {
511
+ if (key.endsWith('.countryTag') &&
512
+ typeof value === 'string' &&
513
+ /^[A-Za-z0-9_]{2,64}$/u.test(value))
514
+ tags.add(value.toUpperCase());
515
+ }
516
+ }
517
+ }
518
+ return [...tags]
519
+ .sort(compareCodeUnits)
520
+ .flatMap((tag) => ['', 'medium/', 'small/'].flatMap((directory) => [
521
+ `gfx/flags/${directory}${tag}.{bmp,dds,png,tga}`,
522
+ `gfx/flags/${directory}${tag}_*.{bmp,dds,png,tga}`,
523
+ ]));
524
+ }
480
525
  function bmFontPagePatterns(files) {
481
526
  const references = files.flatMap((file) => {
482
527
  if (!file.relativePath.toLowerCase().endsWith('.fnt') || file.shadowedBy !== undefined)
@@ -556,21 +601,130 @@ function assertUniqueScenarioIds(scenarios) {
556
601
  seen.add(scenario.id);
557
602
  }
558
603
  }
559
- function scenarioMatrixEvidence(scenes) {
604
+ function scenarioMatrixEvidence(graph, scenes) {
560
605
  const primary = scenes[0];
561
606
  if (primary === undefined)
562
607
  return { offline: true, scenarios: [], changes: [] };
608
+ const visibilityNames = new Set();
609
+ const enabledNames = new Set();
610
+ for (const scripted of graph.scriptedGuis) {
611
+ for (const trigger of scripted.triggerDefinitions) {
612
+ if (trigger.name.endsWith('_visible'))
613
+ visibilityNames.add(trigger.elementName);
614
+ if (trigger.name.endsWith('_click_enabled'))
615
+ enabledNames.add(trigger.elementName);
616
+ }
617
+ for (const property of scripted.propertyDefinitions) {
618
+ if (Object.keys(property.attributes).some((name) => name.toLowerCase() === 'visible'))
619
+ visibilityNames.add(property.elementName);
620
+ if (Object.keys(property.attributes).some((name) => name.toLowerCase() === 'enabled'))
621
+ enabledNames.add(property.elementName);
622
+ }
623
+ }
624
+ for (const scene of scenes) {
625
+ for (const name of Object.keys(scene.scenario.visibility))
626
+ visibilityNames.add(name);
627
+ for (const name of [
628
+ ...Object.keys(scene.scenario.values),
629
+ ...Object.keys(scene.scenario.scriptedGui),
630
+ ]) {
631
+ if (name.endsWith('.visible'))
632
+ visibilityNames.add(name.slice(0, -'.visible'.length));
633
+ if (name.endsWith('.enabled'))
634
+ enabledNames.add(name.slice(0, -'.enabled'.length));
635
+ }
636
+ }
637
+ const branchCoverage = [...new Set([...visibilityNames, ...enabledNames])]
638
+ .sort(compareCodeUnits)
639
+ .flatMap((name) => {
640
+ const matches = scenes
641
+ .map((scene) => ({
642
+ scene,
643
+ elements: scene.elements.filter((element) => element.name === name),
644
+ }))
645
+ .filter(({ elements }) => elements.length > 0);
646
+ if (matches.length === 0)
647
+ return [];
648
+ const shown = matches
649
+ .filter(({ elements }) => elements.some(({ visible }) => visible))
650
+ .map(({ scene }) => scene.scenario.id);
651
+ const hidden = matches
652
+ .filter(({ elements }) => elements.every(({ visible }) => !visible))
653
+ .map(({ scene }) => scene.scenario.id);
654
+ const enabled = matches
655
+ .filter(({ elements }) => elements.some(({ clickable }) => clickable))
656
+ .map(({ scene }) => scene.scenario.id);
657
+ const disabled = matches
658
+ .filter(({ elements }) => elements.every(({ clickable }) => !clickable))
659
+ .map(({ scene }) => scene.scenario.id);
660
+ return [
661
+ {
662
+ element: name,
663
+ sourcePaths: [
664
+ ...new Set(matches.flatMap(({ elements }) => elements.map(({ sourcePath }) => sourcePath))),
665
+ ].sort(compareCodeUnits),
666
+ ...(visibilityNames.has(name)
667
+ ? { visibility: { shown, hidden, covered: shown.length > 0 && hidden.length > 0 } }
668
+ : {}),
669
+ ...(enabledNames.has(name)
670
+ ? { enabled: { enabled, disabled, covered: enabled.length > 0 && disabled.length > 0 } }
671
+ : {}),
672
+ },
673
+ ];
674
+ });
563
675
  const elementKey = (element) => `${element.sourceId}#${element.rowIndex ?? 0}`;
564
676
  const primaryByKey = new Map(primary.elements.map((element) => [elementKey(element), element]));
565
677
  return {
566
678
  offline: true,
679
+ branchCoverage,
680
+ branchCoverageSummary: {
681
+ elements: branchCoverage.length,
682
+ uncoveredVisibility: branchCoverage.filter((entry) => 'visibility' in entry && !entry.visibility.covered).length,
683
+ uncoveredEnabled: branchCoverage.filter((entry) => 'enabled' in entry && !entry.enabled.covered).length,
684
+ },
567
685
  scenarios: scenes.map((scene) => ({
568
686
  scenario: scene.scenario,
569
687
  sourceRevision: scene.sourceRevision,
570
688
  fidelity: scene.fidelity,
689
+ labelMeasurements: scene.elements
690
+ .filter(({ visible, text }) => visible && text !== undefined)
691
+ .map(({ id, name, sourcePath, rect, unclippedRect, text }) => ({
692
+ id,
693
+ name,
694
+ sourcePath,
695
+ box: rect,
696
+ unclippedBox: unclippedRect,
697
+ measuredText: {
698
+ width: text.measuredWidth,
699
+ height: text.measuredHeight,
700
+ metricSource: text.metricSource,
701
+ overflowX: text.overflowX,
702
+ overflowY: text.overflowY,
703
+ },
704
+ })),
705
+ backgroundMeasurements: scene.elements
706
+ .filter(({ visible, sprite, secondarySprite, elementType }) => visible &&
707
+ (sprite !== undefined || secondarySprite !== undefined) &&
708
+ /(?:containerwindow|windowtype|buttontype|icontype|progressbar)/iu.test(elementType))
709
+ .map(({ id, name, parentId, sourcePath, rect, unclippedRect, sprite, secondarySprite }) => ({
710
+ id,
711
+ name,
712
+ parentId,
713
+ sourcePath,
714
+ box: rect,
715
+ unclippedBox: unclippedRect,
716
+ sprite: sprite?.spriteName ?? secondarySprite?.spriteName,
717
+ texture: sprite?.texturePath ?? secondarySprite?.texturePath,
718
+ })),
571
719
  visibleElements: scene.elements
572
720
  .filter(({ visible }) => visible)
573
721
  .map(({ id, name }) => ({ id, name })),
722
+ hiddenElements: scene.elements
723
+ .filter(({ visible }) => !visible)
724
+ .map(({ id, name, visibilityReason }) => ({ id, name, reason: visibilityReason })),
725
+ disabledElements: scene.elements
726
+ .filter(({ disabledReason }) => disabledReason !== undefined)
727
+ .map(({ id, name, disabledReason }) => ({ id, name, reason: disabledReason })),
574
728
  })),
575
729
  changes: scenes.slice(1).map((scene) => {
576
730
  const currentByKey = new Map(scene.elements.map((element) => [elementKey(element), element]));
@@ -581,6 +735,8 @@ function scenarioMatrixEvidence(scenes) {
581
735
  const resized = [];
582
736
  const textChanged = [];
583
737
  const frameChanged = [];
738
+ const disabled = [];
739
+ const enabled = [];
584
740
  for (const key of keys) {
585
741
  const before = primaryByKey.get(key);
586
742
  const after = currentByKey.get(key);
@@ -588,6 +744,10 @@ function scenarioMatrixEvidence(scenes) {
588
744
  shown.push(after.name);
589
745
  if (before?.visible === true && after?.visible !== true)
590
746
  hidden.push(before.name);
747
+ if (before?.clickable === true && after?.clickable === false)
748
+ disabled.push(after.name);
749
+ if (before?.clickable === false && after?.clickable === true)
750
+ enabled.push(after.name);
591
751
  if (before === undefined || after === undefined)
592
752
  continue;
593
753
  if (before.unclippedRect.x !== after.unclippedRect.x ||
@@ -609,6 +769,8 @@ function scenarioMatrixEvidence(scenes) {
609
769
  resized,
610
770
  textChanged,
611
771
  frameChanged,
772
+ disabled,
773
+ enabled,
612
774
  };
613
775
  }),
614
776
  };
@@ -744,7 +906,7 @@ export class ScriptedGuiStudio {
744
906
  const files = mergeScannedFiles(snapshot.files, localisationFiles);
745
907
  return this.graphForFiles(files, `workspace:${workspaceId}`);
746
908
  }
747
- async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = []) {
909
+ async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = []) {
748
910
  const workspace = this.resolver.get(workspaceId, principal);
749
911
  const layoutPatterns = guiLayoutPatterns(workspace);
750
912
  const overlayLayouts = await this.engine.scan(workspaceId, {
@@ -753,12 +915,15 @@ export class ScriptedGuiStudio {
753
915
  }, principal, signal);
754
916
  let layoutFiles = overlayLayouts.files;
755
917
  let layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
756
- if (!layoutGraph.elements.some(({ name }) => name === windowName)) {
918
+ const selectedLayoutElements = selectedElements(layoutGraph, windowName, additionalTemplateNames);
919
+ const requiredNames = new Set([windowName, ...additionalTemplateNames]);
920
+ for (const element of selectedLayoutElements)
921
+ collectNamedAttributes(element.attributes, new Set(['verticalScrollbar', 'horizontalScrollbar', 'scrollbarType', 'scrollbartype']), requiredNames);
922
+ const availableNames = new Set(layoutGraph.elements.map(({ name }) => name));
923
+ if ([...requiredNames].some((name) => !availableNames.has(name))) {
757
924
  const gameLayouts = await this.engine.scan(workspaceId, { patterns: layoutPatterns, rootKinds: ['game'] }, principal, signal);
758
925
  const gameGraph = this.graphForFiles(gameLayouts.files, `game-layout:${workspaceId}`).graph;
759
- const selectedPaths = new Set(gameGraph.elements
760
- .filter(({ name }) => name === windowName)
761
- .map(({ sourcePath }) => sourcePath));
926
+ const selectedPaths = new Set(selectedElements(gameGraph, windowName, [...requiredNames]).map(({ sourcePath }) => sourcePath));
762
927
  layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.files.filter(({ displayPath }) => selectedPaths.has(displayPath)));
763
928
  layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
764
929
  }
@@ -771,7 +936,10 @@ export class ScriptedGuiStudio {
771
936
  const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions.files, localisation.files);
772
937
  const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`);
773
938
  assertTargetWindowAvailable(definitions.graph, windowName);
774
- const referencedPatterns = referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages);
939
+ const referencedPatterns = [
940
+ ...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames),
941
+ ...additionalAssetPatterns,
942
+ ];
775
943
  const exactReferenced = referencedPatterns.length === 0
776
944
  ? []
777
945
  : await this.scanner.scan(workspace, {
@@ -814,7 +982,7 @@ export class ScriptedGuiStudio {
814
982
  const generatedOptions = input.generatedScenarios === undefined
815
983
  ? undefined
816
984
  : parseGeneratedScenarioOptions(input.generatedScenarios);
817
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples));
985
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryFlagPatterns([placeholderScenario, ...explicitRelatedScenarios]));
818
986
  const generatedScenarios = generatedOptions === undefined
819
987
  ? []
820
988
  : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
@@ -844,7 +1012,7 @@ export class ScriptedGuiStudio {
844
1012
  const budget = new RenderBudget();
845
1013
  const beforeScenario = parsePreviewScenario(input.before);
846
1014
  const afterScenario = parsePreviewScenario(input.after);
847
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]));
1015
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryFlagPatterns([beforeScenario, afterScenario]));
848
1016
  const beforeCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, beforeScenario.language);
849
1017
  const afterCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, afterScenario.language);
850
1018
  const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, beforeCatalog);
@@ -893,7 +1061,15 @@ export class ScriptedGuiStudio {
893
1061
  placeholderScenario,
894
1062
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
895
1063
  ...explicitRelatedScenarios,
896
- ], generatedOptions?.textSamples));
1064
+ ], generatedOptions?.textSamples), scenarioTemplateNames([
1065
+ placeholderScenario,
1066
+ ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1067
+ ...explicitRelatedScenarios,
1068
+ ]), scenarioCountryFlagPatterns([
1069
+ placeholderScenario,
1070
+ ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1071
+ ...explicitRelatedScenarios,
1072
+ ]));
897
1073
  const generatedScenarios = generatedOptions === undefined
898
1074
  ? []
899
1075
  : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
@@ -979,6 +1155,35 @@ export class ScriptedGuiStudio {
979
1155
  const baselineScene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, baselineScenario, catalog);
980
1156
  const baselineImage = await renderFull(baselineScene);
981
1157
  const comparison = await compareGuiImages(baselineImage.png, fullImage(render).png, budget, input.signal);
1158
+ let sourceBaselineScene;
1159
+ let sourceBaselineImage;
1160
+ let sourceComparison;
1161
+ if (input.sourceBaseline !== undefined) {
1162
+ const { relativePath, source } = input.sourceBaseline;
1163
+ guiTextPackageFileKind(workspace, relativePath, true);
1164
+ const original = scanned.files.find((file) => file.rootKind === 'mod' &&
1165
+ portableSourcePathKey(file.relativePath) === portableSourcePathKey(relativePath));
1166
+ if (original === undefined)
1167
+ throw new ServiceError('GUI_SOURCE_BASELINE_FILE_MISSING', 'Source baseline must identify a scanned mod-owned GUI file', { relativePath });
1168
+ const bytes = Buffer.from(source, 'utf8');
1169
+ if (bytes.length > SOURCE_MAX_BYTES)
1170
+ throw new ServiceError('GUI_SOURCE_BASELINE_TOO_LARGE', 'Source baseline exceeds the file byte limit');
1171
+ const baselineFiles = overlayGuiTextPackageFiles(scanned.files, [
1172
+ {
1173
+ relativePath,
1174
+ kind: 'gui',
1175
+ bytes,
1176
+ absolutePath: original.absolutePath,
1177
+ loadOrder: original.loadOrder,
1178
+ },
1179
+ ]);
1180
+ const baselineGraph = this.graphForFiles(baselineFiles, `source-baseline:${input.workspaceId}`).graph;
1181
+ if (!baselineGraph.elements.some(({ name }) => name === input.windowName))
1182
+ throw new ServiceError('GUI_SOURCE_BASELINE_WINDOW_MISSING', 'Source baseline does not define the requested GUI window', { relativePath, windowName: input.windowName });
1183
+ sourceBaselineScene = await buildGuiScene(baselineGraph, baselineFiles, input.windowName, scenario, new GuiAssetCatalog(baselineGraph, baselineFiles, budget, scenario.language));
1184
+ sourceBaselineImage = await renderFull(sourceBaselineScene);
1185
+ sourceComparison = await compareGuiImages(sourceBaselineImage.png, fullImage(render).png, budget, input.signal);
1186
+ }
982
1187
  const renderedSourceIds = [
983
1188
  ...scenarioScenes,
984
1189
  ...stateScenes,
@@ -1048,7 +1253,7 @@ export class ScriptedGuiStudio {
1048
1253
  add(`${slug}-validation.json`, 'application/json', `${canonicalJson(completeValidation)}\n`, 'gui-validation-report', [...scenarioScenes, ...stateScenes, ...resolutionScenes]);
1049
1254
  add(`${slug}-scenario-matrix.svg`, 'image/svg+xml', scenarioGallerySvg, 'gui-scripted-scenario-matrix', scenarioScenes);
1050
1255
  add(`${slug}-scenario-matrix.png`, 'image/png', scenarioGalleryPng, 'gui-scripted-scenario-matrix', scenarioScenes);
1051
- add(`${slug}-scenario-matrix.json`, 'application/json', `${canonicalJson(scenarioMatrixEvidence(scenarioScenes))}\n`, 'gui-scripted-scenario-matrix-json', scenarioScenes);
1256
+ add(`${slug}-scenario-matrix.json`, 'application/json', `${canonicalJson(scenarioMatrixEvidence(scanned.graph, scenarioScenes))}\n`, 'gui-scripted-scenario-matrix-json', scenarioScenes);
1052
1257
  add(`${slug}-state-matrix.svg`, 'image/svg+xml', stateGallerySvg, 'gui-state-matrix', stateScenes);
1053
1258
  add(`${slug}-state-matrix.png`, 'image/png', stateGalleryPng, 'gui-state-matrix', stateScenes);
1054
1259
  add(`${slug}-state-matrix.json`, 'application/json', `${canonicalJson({ offline: true, scenarios: stateScenes.map(({ scenario: stateScenario, fidelity }) => ({ scenario: stateScenario, fidelity })) })}\n`, 'gui-state-matrix-json', stateScenes);
@@ -1060,6 +1265,23 @@ export class ScriptedGuiStudio {
1060
1265
  baselineScene,
1061
1266
  ]);
1062
1267
  add(`${slug}-comparison.json`, 'application/json', comparison.json, 'gui-before-after-comparison', [scene, baselineScene]);
1268
+ if (sourceBaselineScene !== undefined &&
1269
+ sourceBaselineImage !== undefined &&
1270
+ sourceComparison !== undefined) {
1271
+ add(`${slug}-source-baseline.png`, 'image/png', sourceBaselineImage.png, 'gui-source-baseline', [sourceBaselineScene]);
1272
+ add(`${slug}-source-comparison.png`, 'image/png', sourceComparison.png, 'gui-source-comparison', [sourceBaselineScene, scene]);
1273
+ add(`${slug}-source-comparison.json`, 'application/json', `${canonicalJson({
1274
+ kind: 'gui-source-comparison',
1275
+ scenario,
1276
+ before: {
1277
+ sourceRevision: sourceBaselineScene.sourceRevision,
1278
+ sourcePath: input.sourceBaseline.relativePath,
1279
+ sourceHash: sha256Bytes(Buffer.from(input.sourceBaseline.source, 'utf8')),
1280
+ },
1281
+ after: { sourceRevision: scene.sourceRevision },
1282
+ comparison: JSON.parse(sourceComparison.json),
1283
+ })}\n`, 'gui-source-comparison', [sourceBaselineScene, scene]);
1284
+ }
1063
1285
  const stored = await this.artifacts.withAtomicChunkedWrites(workspace, writes, (artifacts) => Promise.resolve([...artifacts]), input.signal);
1064
1286
  return {
1065
1287
  artifacts: stored,
@@ -1069,6 +1291,7 @@ export class ScriptedGuiStudio {
1069
1291
  stateScenes,
1070
1292
  resolutionScenes,
1071
1293
  comparison,
1294
+ ...(sourceComparison === undefined ? {} : { sourceComparison }),
1072
1295
  validation: completeValidation,
1073
1296
  };
1074
1297
  }
@@ -1193,7 +1416,7 @@ export class ScriptedGuiStudio {
1193
1416
  const previewScenario = input.scenario === undefined ? undefined : parsePreviewScenario(input.scenario);
1194
1417
  const initiallyScanned = input.windowName === undefined
1195
1418
  ? await this.scan(input.workspaceId, input.principal, input.signal)
1196
- : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]));
1419
+ : await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryFlagPatterns([previewScenario]));
1197
1420
  const exactTargets = await this.scanner.scan(workspace, {
1198
1421
  patterns: prepared.map(({ relativePath }) => relativePath),
1199
1422
  ...(input.signal === undefined ? {} : { signal: input.signal }),
@@ -1213,7 +1436,10 @@ export class ScriptedGuiStudio {
1213
1436
  const scenario = parsePreviewScenario(input.scenario);
1214
1437
  const renderBudget = new RenderBudget();
1215
1438
  let proposedGraph = buildGuiSourceGraph(proposedFiles, this.engine.indexFiles(proposedFiles));
1216
- const proposedAssetPatterns = referencedAssetPatternsForWindow(proposedGraph, input.windowName, [], [scenario.language]);
1439
+ const proposedAssetPatterns = [
1440
+ ...referencedAssetPatternsForWindow(proposedGraph, input.windowName, [], [scenario.language], scenarioTemplateNames([scenario])),
1441
+ ...scenarioCountryFlagPatterns([scenario]),
1442
+ ];
1217
1443
  const exactProposedAssets = proposedAssetPatterns.length === 0
1218
1444
  ? []
1219
1445
  : await this.scanner.scan(workspace, {
@@ -235,6 +235,7 @@ export async function renderGui(studio, input, context) {
235
235
  ...(input.comparisonScenario === undefined
236
236
  ? {}
237
237
  : { comparisonScenario: input.comparisonScenario }),
238
+ ...(input.sourceBaseline === undefined ? {} : { sourceBaseline: input.sourceBaseline }),
238
239
  ...(context.principal === undefined ? {} : { principal: context.principal }),
239
240
  ...(context.signal === undefined ? {} : { signal: context.signal }),
240
241
  });
@@ -257,6 +258,14 @@ export async function renderGui(studio, input, context) {
257
258
  changedPixels: rendered.comparison.changedPixels,
258
259
  changedRatio: rendered.comparison.changedRatio,
259
260
  },
261
+ ...(rendered.sourceComparison === undefined
262
+ ? {}
263
+ : {
264
+ sourceComparison: {
265
+ changedPixels: rendered.sourceComparison.changedPixels,
266
+ changedRatio: rendered.sourceComparison.changedRatio,
267
+ },
268
+ }),
260
269
  fidelityCounts: Object.fromEntries(Object.entries(rendered.render.fidelity).map(([key, values]) => [key, values.length])),
261
270
  offlineRepresentation: true,
262
271
  });
@@ -207,6 +207,36 @@ function validateScenarioExpectations(scene, diagnostics) {
207
207
  function validateButtonLabelsAndBackgrounds(scene, diagnostics) {
208
208
  const visible = scene.elements.filter((element) => element.visible);
209
209
  const byId = new Map(scene.elements.map((element) => [element.id, element]));
210
+ const backgrounds = new Map();
211
+ for (const element of visible) {
212
+ if (element.elementType !== 'background' ||
213
+ element.parentId === undefined ||
214
+ area(element.rect) === 0)
215
+ continue;
216
+ const previous = backgrounds.get(element.parentId);
217
+ const rect = element.unclippedRect;
218
+ const x = Math.min(previous?.x ?? rect.x, rect.x);
219
+ const y = Math.min(previous?.y ?? rect.y, rect.y);
220
+ backgrounds.set(element.parentId, {
221
+ x,
222
+ y,
223
+ width: Math.max(previous === undefined ? x : previous.x + previous.width, rect.x + rect.width) - x,
224
+ height: Math.max(previous === undefined ? y : previous.y + previous.height, rect.y + rect.height) -
225
+ y,
226
+ });
227
+ }
228
+ const backgroundOwner = (element) => {
229
+ const visited = new Set();
230
+ let parent = element.parentId === undefined ? undefined : byId.get(element.parentId);
231
+ while (parent !== undefined && !visited.has(parent.id)) {
232
+ visited.add(parent.id);
233
+ const rect = parent.sprite === undefined ? backgrounds.get(parent.id) : parent.unclippedRect;
234
+ if (rect !== undefined)
235
+ return { owner: parent, rect };
236
+ parent = parent.parentId === undefined ? undefined : byId.get(parent.parentId);
237
+ }
238
+ return undefined;
239
+ };
210
240
  const buttons = visible.filter((element) => element.clickable && /button/iu.test(element.elementType));
211
241
  const labels = visible.filter((element) => element.text !== undefined);
212
242
  const inferredButtonByLabel = new Map();
@@ -249,22 +279,18 @@ function validateButtonLabelsAndBackgrounds(scene, diagnostics) {
249
279
  deltaY: delta.y,
250
280
  }));
251
281
  }
252
- let parent = label.parentId === undefined ? undefined : byId.get(label.parentId);
253
- while (parent !== undefined && parent.sprite === undefined)
254
- parent = parent.parentId === undefined ? undefined : byId.get(parent.parentId);
282
+ const parent = backgroundOwner(label);
255
283
  if (parent !== undefined &&
256
- !contains(parent.unclippedRect, label.unclippedRect, 0.5) &&
257
- intersection(parent.unclippedRect, label.unclippedRect) !== undefined)
258
- diagnostics.push(issue('GUI_CONTENT_CROSSES_BACKGROUND_EDGE', 'warning', 'layout', `${label.name} crosses the visible background boundary of ${parent.name} in scenario ${scene.scenario.id}.`, label, { scenarioId: scene.scenario.id, element: label.name, background: parent.name }));
284
+ !contains(parent.rect, label.unclippedRect, 0.5) &&
285
+ intersection(parent.rect, label.unclippedRect) !== undefined)
286
+ diagnostics.push(issue('GUI_CONTENT_CROSSES_BACKGROUND_EDGE', 'warning', 'layout', `${label.name} crosses the visible background boundary of ${parent.owner.name} in scenario ${scene.scenario.id}.`, label, { scenarioId: scene.scenario.id, element: label.name, background: parent.owner.name }));
259
287
  }
260
288
  for (const control of visible.filter((element) => element.clickable && element.text === undefined)) {
261
- let parent = control.parentId === undefined ? undefined : byId.get(control.parentId);
262
- while (parent !== undefined && parent.sprite === undefined)
263
- parent = parent.parentId === undefined ? undefined : byId.get(parent.parentId);
289
+ const parent = backgroundOwner(control);
264
290
  if (parent !== undefined &&
265
- !contains(parent.unclippedRect, control.unclippedRect, 0.5) &&
266
- intersection(parent.unclippedRect, control.unclippedRect) !== undefined)
267
- diagnostics.push(issue('GUI_CONTENT_CROSSES_BACKGROUND_EDGE', 'warning', 'layout', `${control.name} crosses the visible background boundary of ${parent.name} in scenario ${scene.scenario.id}.`, control, { scenarioId: scene.scenario.id, element: control.name, background: parent.name }));
291
+ !contains(parent.rect, control.unclippedRect, 0.5) &&
292
+ intersection(parent.rect, control.unclippedRect) !== undefined)
293
+ diagnostics.push(issue('GUI_CONTENT_CROSSES_BACKGROUND_EDGE', 'warning', 'layout', `${control.name} crosses the visible background boundary of ${parent.owner.name} in scenario ${scene.scenario.id}.`, control, { scenarioId: scene.scenario.id, element: control.name, background: parent.owner.name }));
268
294
  }
269
295
  }
270
296
  function sameRect(left, right) {
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
3
3
  import { compareCodeUnits, deterministicId, sha256Bytes } from '../core/canonical.js';
4
4
  import { ServiceError } from '../core/result.js';
5
- import { applyReplacements, assignments, encodeSource, parseLocalisation, } from '../core/source/index.js';
5
+ import { applyReplacements, assignments, encodeSource, parseLocalisation, parseClausewitz, } from '../core/source/index.js';
6
6
  import { rgbKey } from './bmp.js';
7
7
  import { MAP_MASK_CELL_LIMIT, MAP_POLYGON_WORK_LIMIT, MAP_SELECTED_OFFSET_BYTES, MAP_SELECTED_PIXEL_LIMIT, } from './limits.js';
8
8
  import { derivedStateCapital, encodeTextDocument, MapWorkspaceIndex, parseTextDocument, } from './model.js';
@@ -2718,6 +2718,72 @@ function renumberMapping(existingIds, operation) {
2718
2718
  ...(targetOccupied ? [[operation.toId, operation.fromId]] : []),
2719
2719
  ]);
2720
2720
  }
2721
+ const EXTERNAL_SCRIPT_REFERENCE_KEYS = {
2722
+ province: new Set(['province', 'province_id', 'start_province', 'target_province']),
2723
+ state: new Set([
2724
+ 'state',
2725
+ 'state_id',
2726
+ 'start_state',
2727
+ 'target_state',
2728
+ 'owns_state',
2729
+ 'controls_state',
2730
+ 'transfer_state',
2731
+ 'transfer_state_to',
2732
+ ]),
2733
+ 'strategic-region': new Set(['strategic_region', 'strategic_region_id']),
2734
+ };
2735
+ function externalScriptFile(relativePath) {
2736
+ return /^(?:events|common\/(?:national_focus|decisions|scripted_effects|scripted_triggers|on_actions|ideas|characters)|history\/countries)\/.+\.txt$/u.test(relativePath);
2737
+ }
2738
+ function remapExternalScriptReferences(index, changes, mapping, entity, operationId) {
2739
+ const keys = EXTERNAL_SCRIPT_REFERENCE_KEYS[entity];
2740
+ for (const file of index.sourceFiles) {
2741
+ if (file.shadowedBy !== undefined || !externalScriptFile(file.relativePath))
2742
+ continue;
2743
+ const document = parseClausewitz(file.bytes, file.displayPath);
2744
+ if (document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'))
2745
+ throw new ServiceError('MAP_EXTERNAL_SCRIPT_PARSE_BLOCKED', 'A selected external script cannot be safely inspected for typed map references', {
2746
+ relativePath: file.relativePath,
2747
+ diagnosticCodes: document.diagnostics.map(({ code }) => code),
2748
+ });
2749
+ const replacements = [];
2750
+ const visit = (entry) => {
2751
+ if (entry.type === 'block') {
2752
+ for (const child of entry.entries)
2753
+ visit(child);
2754
+ return;
2755
+ }
2756
+ if (entry.type !== 'assignment')
2757
+ return;
2758
+ if (entry.value.type === 'block') {
2759
+ for (const child of entry.value.entries)
2760
+ visit(child);
2761
+ return;
2762
+ }
2763
+ if (!keys.has(entry.key.value) || !/^\d+$/u.test(entry.value.value))
2764
+ return;
2765
+ const replacement = mapping.get(Number(entry.value.value));
2766
+ if (replacement === undefined)
2767
+ return;
2768
+ if (file.rootKind !== 'mod' && file.rootKind !== 'fixture')
2769
+ throw new ServiceError('MAP_EXTERNAL_REFERENCE_READ_ONLY', 'A typed external map reference is owned by a read-only source', {
2770
+ relativePath: file.relativePath,
2771
+ rootKind: file.rootKind,
2772
+ key: entry.key.value,
2773
+ id: entry.value.value,
2774
+ });
2775
+ replacements.push({
2776
+ start: entry.value.start,
2777
+ end: entry.value.end,
2778
+ text: String(replacement),
2779
+ description: `Renumber typed ${entity} reference in ${entry.key.value}`,
2780
+ });
2781
+ };
2782
+ visit(document.root);
2783
+ if (replacements.length > 0)
2784
+ addChange(changes, file.relativePath, applyReplacements(document, replacements), operationId, 'text/plain');
2785
+ }
2786
+ }
2721
2787
  function renameLocalisationKeys(index, changes, keys, operationId) {
2722
2788
  if (keys.size === 0)
2723
2789
  return;
@@ -2838,6 +2904,7 @@ function applyRenumberMapEntity(base, changes, operation) {
2838
2904
  commitRegion(changes, region, uniqueSorted(region.provinces.map((id) => remapProvinceId(mapping, id))), operation.id);
2839
2905
  }
2840
2906
  remapReferenceFiles(index, changes, mapping, operation.id);
2907
+ remapExternalScriptReferences(index, changes, mapping, operation.entity, operation.id);
2841
2908
  if (operation.renameLocalisation ?? true) {
2842
2909
  const keys = new Map();
2843
2910
  for (const [fromId, toId] of mapping) {
@@ -2863,6 +2930,7 @@ function applyRenumberMapEntity(base, changes, operation) {
2863
2930
  commitState(changes, state, data, { id: true, ...(standardName ? { name: true } : {}) }, operation.id);
2864
2931
  }
2865
2932
  remapBuildingPositionStateIds(index, changes, mapping, operation.id);
2933
+ remapExternalScriptReferences(index, changes, mapping, operation.entity, operation.id);
2866
2934
  if (operation.renameLocalisation ?? true)
2867
2935
  renameLocalisationKeys(currentIndex(base, changes), changes, localisationKeys, operation.id);
2868
2936
  return;
@@ -2881,6 +2949,7 @@ function applyRenumberMapEntity(base, changes, operation) {
2881
2949
  addChange(changes, region.file.relativePath, patchRegion(region, region.provinces, { id: toId, ...(standardName ? { name } : {}) }, false), operation.id, 'text/plain');
2882
2950
  }
2883
2951
  remapWeatherPositionRegionIds(index, changes, mapping, operation.id);
2952
+ remapExternalScriptReferences(index, changes, mapping, operation.entity, operation.id);
2884
2953
  if (operation.renameLocalisation ?? true)
2885
2954
  renameLocalisationKeys(currentIndex(base, changes), changes, localisationKeys, operation.id);
2886
2955
  }