hoi4-agent-tools 3.2.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 (99) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -0
  3. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +11 -1
  4. package/dist/hoi4_agent_tools/core/condition-evaluator.js +18 -9
  5. package/dist/hoi4_agent_tools/core/helper-expansion.js +3 -0
  6. package/dist/hoi4_agent_tools/core/job-worker-host.js +3 -0
  7. package/dist/hoi4_agent_tools/core/operation-tasks.js +7 -0
  8. package/dist/hoi4_agent_tools/core/scenario-model.js +85 -0
  9. package/dist/hoi4_agent_tools/event/comparison-selection.js +78 -0
  10. package/dist/hoi4_agent_tools/event/helper-expansion.js +48 -0
  11. package/dist/hoi4_agent_tools/event/operations.js +16 -0
  12. package/dist/hoi4_agent_tools/event/service.js +21 -2
  13. package/dist/hoi4_agent_tools/focus/compact.js +241 -27
  14. package/dist/hoi4_agent_tools/focus/layout.js +119 -8
  15. package/dist/hoi4_agent_tools/focus/render.js +62 -0
  16. package/dist/hoi4_agent_tools/focus/scenario.js +99 -0
  17. package/dist/hoi4_agent_tools/focus/tool-operations.js +30 -4
  18. package/dist/hoi4_agent_tools/gui/assets.js +54 -4
  19. package/dist/hoi4_agent_tools/gui/layout.js +935 -104
  20. package/dist/hoi4_agent_tools/gui/renderer.js +47 -17
  21. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +2 -23
  22. package/dist/hoi4_agent_tools/gui/shader.js +515 -0
  23. package/dist/hoi4_agent_tools/gui/source-graph.js +166 -6
  24. package/dist/hoi4_agent_tools/gui/studio.js +245 -19
  25. package/dist/hoi4_agent_tools/gui/tool-operations.js +9 -0
  26. package/dist/hoi4_agent_tools/gui/validators.js +38 -12
  27. package/dist/hoi4_agent_tools/map/operations.js +70 -1
  28. package/dist/hoi4_agent_tools/map/render.js +70 -1
  29. package/dist/hoi4_agent_tools/map/service.js +30 -6
  30. package/dist/hoi4_agent_tools/map/tool-operations.js +67 -1
  31. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  32. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +3 -0
  33. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +90 -0
  34. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -1
  35. package/dist/hoi4_agent_tools/mcp/tools/focus.js +14 -4
  36. package/dist/hoi4_agent_tools/mcp/tools/gui.js +5 -1
  37. package/dist/hoi4_agent_tools/mcp/tools/map.js +12 -2
  38. package/dist/hoi4_agent_tools/mcp/tools/probability.js +5 -0
  39. package/dist/hoi4_agent_tools/mechanic/assertions.js +112 -0
  40. package/dist/hoi4_agent_tools/mechanic/interpreter.js +619 -0
  41. package/dist/hoi4_agent_tools/mechanic/service.js +145 -0
  42. package/dist/hoi4_agent_tools/mechanic/source.js +89 -0
  43. package/dist/hoi4_agent_tools/package-check/service.js +231 -0
  44. package/dist/hoi4_agent_tools/probability/evaluation.js +6 -1
  45. package/dist/hoi4_agent_tools/probability/operations.js +8 -0
  46. package/dist/hoi4_agent_tools/probability/service.js +33 -12
  47. package/dist/hoi4_agent_tools/probability/source-analysis.js +1 -0
  48. package/dist/hoi4_agent_tools/scenario-suite/service.js +341 -0
  49. package/dist/hoi4_agent_tools/schemas/event.js +8 -0
  50. package/dist/hoi4_agent_tools/schemas/focus-requests.js +40 -1
  51. package/dist/hoi4_agent_tools/schemas/gui-requests.js +8 -0
  52. package/dist/hoi4_agent_tools/schemas/map-requests.js +22 -1
  53. package/dist/hoi4_agent_tools/schemas/scenarios.js +264 -0
  54. package/dist/hoi4_agent_tools/schemas/technology.js +7 -0
  55. package/dist/hoi4_agent_tools/technology/graph.js +25 -10
  56. package/dist/hoi4_agent_tools/technology/render.js +286 -31
  57. package/dist/hoi4_agent_tools/technology/service.js +55 -8
  58. package/dist/hoi4_agent_tools/technology/source-analysis.js +166 -3
  59. package/dist/hoi4_agent_tools/version.js +1 -1
  60. package/docs/README.md +1 -0
  61. package/docs/events.md +3 -1
  62. package/docs/focus.md +10 -0
  63. package/docs/gui.md +49 -3
  64. package/docs/map.md +8 -0
  65. package/docs/mechanics.md +70 -0
  66. package/docs/probability.md +3 -1
  67. package/docs/technology.md +7 -2
  68. package/package.json +2 -1
  69. package/schemas/configuration.schema.json +1 -1
  70. package/schemas/continuous-focus-palette.schema.json +1 -1
  71. package/schemas/custom-weighted-pool.schema.json +1 -1
  72. package/schemas/decision-inspect-input.schema.json +1 -1
  73. package/schemas/event-feature-manifest.schema.json +1 -1
  74. package/schemas/focus-plan.schema.json +1 -1
  75. package/schemas/focus-planning-sidecar.schema.json +1 -1
  76. package/schemas/gui-animation-source.schema.json +1 -1
  77. package/schemas/gui-helper.schema.json +1 -1
  78. package/schemas/gui-scenario.schema.json +1 -1
  79. package/schemas/helper-expansion-request.schema.json +1 -1
  80. package/schemas/helper-expansion-summary.schema.json +1 -1
  81. package/schemas/impact-inspect-input.schema.json +1 -1
  82. package/schemas/map-operation.schema.json +1 -1
  83. package/schemas/mechanic-test-input.schema.json +650 -0
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +164 -0
  86. package/schemas/package-manifest.schema.json +142 -0
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/scenario-suite.schema.json +1004 -0
  97. package/schemas/scenario-test-input.schema.json +1057 -0
  98. package/schemas/shared-scenario.schema.json +237 -0
  99. package/server.json +2 -2
@@ -19,6 +19,15 @@ const GENERATED_LAYER_ROWS = 32;
19
19
  const GENERATED_BAND_GAP = 32;
20
20
  const PADDING = 42;
21
21
  const HEADER_HEIGHT = 86;
22
+ function itemBackgroundSprites(sprite) {
23
+ if (!sprite.includes('_unavailable_item_bg'))
24
+ return [sprite];
25
+ return [
26
+ sprite,
27
+ sprite.replace('_unavailable_item_bg', '_available_item_bg'),
28
+ sprite.replace('_unavailable_item_bg', '_researched_item_bg'),
29
+ ];
30
+ }
22
31
  function escapeXml(value) {
23
32
  return value
24
33
  .replaceAll('&', '&')
@@ -31,6 +40,7 @@ function technologyNode(graph, technologyId, placement) {
31
40
  const technology = graph.technologies.find(({ id }) => id === technologyId);
32
41
  if (technology === undefined)
33
42
  return undefined;
43
+ const itemLayout = graph.itemLayouts.find(({ id }) => id === placement?.itemLayoutId);
34
44
  return {
35
45
  id: technology.id,
36
46
  label: technology.localisation.name ?? technology.id,
@@ -48,6 +58,28 @@ function technologyNode(graph, technologyId, placement) {
48
58
  sourceLine: technology.source.location.start.line,
49
59
  layoutSize: placement?.layoutSize ?? technology.layoutSize,
50
60
  iconSprite: technology.icon.sprite,
61
+ ...(itemLayout?.backgroundSprite === undefined
62
+ ? {}
63
+ : { itemBackgroundSprite: itemLayout.backgroundSprite }),
64
+ ...(itemLayout?.iconPosition === undefined ? {} : { iconPosition: itemLayout.iconPosition }),
65
+ ...(itemLayout?.designTeamIcon === undefined
66
+ ? {}
67
+ : { designTeamIcon: itemLayout.designTeamIcon }),
68
+ ...(itemLayout?.namePosition === undefined ? {} : { namePosition: itemLayout.namePosition }),
69
+ ...(placement === undefined || technology.subTechnologies.length === 0
70
+ ? {}
71
+ : {
72
+ subTechnologies: technology.subTechnologies.map((id, index) => {
73
+ const definition = graph.technologies.find((candidate) => candidate.id === id);
74
+ const slot = itemLayout?.subTechnologySlots?.find((candidate) => candidate.index === index);
75
+ return {
76
+ id,
77
+ ...(definition === undefined ? {} : { iconSprite: definition.icon.sprite }),
78
+ placementStatus: slot === undefined ? 'missing_source_slot' : 'source_slot',
79
+ ...(slot === undefined ? {} : { slot }),
80
+ };
81
+ }),
82
+ }),
51
83
  ...(placement === undefined ? {} : { placement }),
52
84
  };
53
85
  }
@@ -58,10 +90,52 @@ function sortedUniqueNodes(values) {
58
90
  result.set(value.id, value);
59
91
  return [...result.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
60
92
  }
93
+ function presentScenario(graph, nodes, scenario) {
94
+ const known = new Map(graph.technologies.map((technology) => [technology.id, technology]));
95
+ const researched = new Set(scenario.researchedTechnologyIds);
96
+ for (const id of researched)
97
+ if (!known.has(id))
98
+ throw new ServiceError('TECH_RENDER_SCENARIO_TECH_UNKNOWN', `Scenario references unknown researched technology ${id}`, { technologyId: id });
99
+ return nodes.map((node) => {
100
+ const technology = known.get(node.id);
101
+ if (technology === undefined)
102
+ return node;
103
+ const missingPrerequisiteIds = graph.edges
104
+ .filter(({ kind, to, from }) => kind === 'prerequisite' && to === node.id && !researched.has(from))
105
+ .map(({ from }) => from)
106
+ .sort(compareCodeUnits);
107
+ const exclusiveConflictIds = graph.edges
108
+ .filter(({ kind, to, from }) => kind === 'exclusive' &&
109
+ ((to === node.id && researched.has(from)) || (from === node.id && researched.has(to))))
110
+ .map(({ to, from }) => (to === node.id ? from : to))
111
+ .sort(compareCodeUnits);
112
+ const parsedYear = Number(technology.startYear);
113
+ const aheadOfTimeYears = technology.startYear !== undefined &&
114
+ /^\d+$/u.test(technology.startYear) &&
115
+ Number.isSafeInteger(parsedYear)
116
+ ? Math.max(0, parsedYear - scenario.year)
117
+ : undefined;
118
+ const scenarioStatus = researched.has(node.id)
119
+ ? 'researched'
120
+ : exclusiveConflictIds.length > 0
121
+ ? 'exclusive_conflict'
122
+ : missingPrerequisiteIds.length > 0
123
+ ? 'missing_prerequisite'
124
+ : 'structural_candidate';
125
+ return {
126
+ ...node,
127
+ scenarioStatus,
128
+ missingPrerequisiteIds,
129
+ exclusiveConflictIds,
130
+ ...(aheadOfTimeYears === undefined ? {} : { aheadOfTimeYears }),
131
+ };
132
+ });
133
+ }
61
134
  function viewSelection(graph, options) {
62
135
  const nodes = [];
63
136
  const edges = [];
64
137
  let yearMarkers = [];
138
+ let backgrounds = [];
65
139
  let title;
66
140
  let sourceAccurate = false;
67
141
  if (options.view === 'summary') {
@@ -117,8 +191,9 @@ function viewSelection(graph, options) {
117
191
  edges.push(...graph.edges
118
192
  .filter(({ from, to }) => selected.has(from) && selected.has(to))
119
193
  .map(({ id, from, to, kind }) => ({ id, from, to, kind })));
120
- sourceAccurate = true;
194
+ sourceAccurate = nodes.every(({ placement }) => placement?.geometryStatus === 'source_pixel');
121
195
  yearMarkers = graph.yearMarkers.filter(({ folderId }) => folderId === options.folderId);
196
+ backgrounds = (graph.backgrounds ?? []).filter(({ folderId }) => folderId === options.folderId);
122
197
  }
123
198
  else if (options.view === 'dependencies' || options.view === 'technology') {
124
199
  const ids = options.technologyId === undefined
@@ -410,6 +485,7 @@ function viewSelection(graph, options) {
410
485
  nodes: selectedNodes,
411
486
  edges: selectedEdges,
412
487
  yearMarkers,
488
+ backgrounds,
413
489
  sourceAccurate,
414
490
  title,
415
491
  payload: {
@@ -428,7 +504,7 @@ function viewSelection(graph, options) {
428
504
  function issueKey(issue) {
429
505
  return sha256Bytes(canonicalJson({ code: issue.code, details: issue.details })).slice(0, 20);
430
506
  }
431
- function layoutFolder(nodes, yearMarkers) {
507
+ function layoutFolder(nodes, yearMarkers, backgrounds) {
432
508
  const dimensions = (node) => node.placement?.layoutWidth !== undefined && node.placement.layoutHeight !== undefined
433
509
  ? { width: node.placement.layoutWidth, height: node.placement.layoutHeight }
434
510
  : node.layoutSize === 'small'
@@ -436,23 +512,24 @@ function layoutFolder(nodes, yearMarkers) {
436
512
  : { width: LARGE_NODE_WIDTH, height: LARGE_NODE_HEIGHT };
437
513
  const sourcePixel = nodes.filter(({ placement }) => placement?.pixelX !== undefined && placement.pixelY !== undefined);
438
514
  if (sourcePixel.length === nodes.length && sourcePixel.length > 0) {
515
+ const placedBackgrounds = backgrounds.filter(({ name }) => name !== 'folder_panel');
439
516
  const sourceNodeX = (node) => node.placement.pixelX + (node.placement?.layoutOffsetX ?? 0);
440
517
  const sourceNodeY = (node) => node.placement.pixelY + (node.placement?.layoutOffsetY ?? 0);
441
- const minimumX = Math.min(...sourcePixel.map(sourceNodeX), ...yearMarkers.map(({ position }) => position.x));
442
- const minimumY = Math.min(...sourcePixel.map(sourceNodeY), ...yearMarkers.map(({ position }) => position.y));
518
+ const minimumX = Math.min(...sourcePixel.map(sourceNodeX), ...yearMarkers.map(({ position }) => position.x), ...placedBackgrounds.map(({ position }) => position.x));
519
+ const minimumY = Math.min(...sourcePixel.map(sourceNodeY), ...yearMarkers.map(({ position }) => position.y), ...placedBackgrounds.map(({ position }) => position.y));
443
520
  const positionedNodes = sourcePixel.map((node) => {
444
521
  const size = dimensions(node);
445
522
  return {
446
523
  ...node,
447
524
  x: PADDING + sourceNodeX(node) - minimumX,
448
- y: HEADER_HEIGHT + PADDING + sourceNodeY(node) - minimumY,
525
+ y: PADDING + sourceNodeY(node) - minimumY,
449
526
  ...size,
450
527
  };
451
528
  });
452
529
  const positionedYearMarkers = yearMarkers.map((marker) => ({
453
530
  ...marker,
454
531
  x: PADDING + marker.position.x - minimumX,
455
- y: HEADER_HEIGHT + PADDING + marker.position.y - minimumY,
532
+ y: PADDING + marker.position.y - minimumY,
456
533
  }));
457
534
  const xValues = yearMarkers.map(({ position }) => position.x);
458
535
  const yValues = yearMarkers.map(({ position }) => position.y);
@@ -571,9 +648,13 @@ function colours(kind) {
571
648
  removed: { fill: '#4a2828', stroke: '#ef7d7d' },
572
649
  }[kind];
573
650
  }
574
- function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris, yearMarkers, yearAxis) {
575
- const maximumX = Math.max(PADDING + LARGE_NODE_WIDTH, ...positioned.map(({ x, width }) => x + width), ...yearMarkers.map(({ x }) => x + 70));
576
- const maximumY = Math.max(HEADER_HEIGHT + LARGE_NODE_HEIGHT, ...positioned.map(({ y, height }) => y + height), ...yearMarkers.map(({ y }) => y + 30));
651
+ function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris, spriteDimensions, yearMarkers, backgrounds, yearAxis, folderView = false) {
652
+ const maximumX = Math.max(PADDING + LARGE_NODE_WIDTH, ...positioned.map(({ x, width }) => x + width), ...positioned.flatMap(({ x, subTechnologies }) => (subTechnologies ?? []).flatMap(({ slot }) => slot === undefined ? [] : [x + slot.position.x + slot.size.width])), ...yearMarkers.map(({ x }) => x + 70), ...backgrounds
653
+ .filter(({ name }) => name !== 'folder_panel')
654
+ .map(({ x, size, sprite }) => x + (size.width ?? spriteDimensions[sprite]?.width ?? 0)));
655
+ const maximumY = Math.max((folderView ? PADDING : HEADER_HEIGHT) + LARGE_NODE_HEIGHT, ...positioned.map(({ y, height }) => y + height), ...positioned.flatMap(({ y, subTechnologies }) => (subTechnologies ?? []).flatMap(({ slot }) => slot === undefined ? [] : [y + slot.position.y + slot.size.height])), ...yearMarkers.map(({ y }) => y + 30), ...backgrounds
656
+ .filter(({ name }) => name !== 'folder_panel')
657
+ .map(({ y, size, sprite }) => y + (size.height ?? spriteDimensions[sprite]?.height ?? 0)));
577
658
  const width = Math.ceil(maximumX + PADDING);
578
659
  const height = Math.ceil(maximumY + PADDING);
579
660
  if (width > 16_384 || height > 16_384 || width * height > RENDER_MAX_PIXELS)
@@ -583,35 +664,134 @@ function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris, year
583
664
  });
584
665
  const byId = new Map(positioned.map((node) => [node.id, node]));
585
666
  const text = new DeterministicSvgTextRenderer();
667
+ const panel = folderView ? backgrounds.find(({ name }) => name === 'folder_panel') : undefined;
668
+ const panelDimensions = panel === undefined ? undefined : spriteDimensions[panel.sprite];
669
+ const panelUri = panel === undefined ? undefined : iconDataUris[panel.sprite];
670
+ const borderWidth = panelDimensions?.borderWidth ?? Math.floor((panelDimensions?.width ?? 0) / 3);
671
+ const borderHeight = panelDimensions?.borderHeight ?? Math.floor((panelDimensions?.height ?? 0) / 3);
672
+ const tileWidth = (panelDimensions?.width ?? 0) - 2 * borderWidth;
673
+ const tileHeight = (panelDimensions?.height ?? 0) - 2 * borderHeight;
674
+ const panelPattern = panelUri !== undefined && panelDimensions !== undefined && tileWidth > 0 && tileHeight > 0
675
+ ? `<pattern id="tech-folder-panel" patternUnits="userSpaceOnUse" width="${tileWidth}" height="${tileHeight}"><image href="${escapeXml(panelUri)}" x="${-borderWidth}" y="${-borderHeight}" width="${panelDimensions.width}" height="${panelDimensions.height}"/></pattern>`
676
+ : '';
677
+ const backgroundSvg = backgrounds.flatMap((background) => {
678
+ if (background.name === 'folder_panel')
679
+ return [];
680
+ const uri = iconDataUris[background.sprite];
681
+ const width = background.size.width ?? spriteDimensions[background.sprite]?.width;
682
+ const height = background.size.height ?? spriteDimensions[background.sprite]?.height;
683
+ if (uri === undefined || width === undefined || height === undefined)
684
+ return [];
685
+ return [
686
+ `<image data-tech-background="${escapeXml(background.name)}" data-sprite="${escapeXml(background.sprite)}" data-source-path="${escapeXml(background.sourcePath)}" data-source-line="${background.location.start.line}" href="${escapeXml(uri)}" x="${background.x}" y="${background.y}" width="${width}" height="${height}" preserveAspectRatio="xMinYMin meet"/>`,
687
+ ];
688
+ });
586
689
  const yearGuideSvg = yearMarkers.map((marker) => {
587
- const guide = yearAxis === 'vertical'
588
- ? `<line x1="${PADDING}" y1="${marker.y + 15}" x2="${width - PADDING}" y2="${marker.y + 15}" stroke="#34495c" stroke-width="1" stroke-dasharray="5 7"/>`
589
- : `<line x1="${marker.x + 24}" y1="${HEADER_HEIGHT}" x2="${marker.x + 24}" y2="${height - PADDING}" stroke="#34495c" stroke-width="1" stroke-dasharray="5 7"/>`;
690
+ const guide = folderView
691
+ ? ''
692
+ : yearAxis === 'vertical'
693
+ ? `<line x1="${PADDING}" y1="${marker.y + 15}" x2="${width - PADDING}" y2="${marker.y + 15}" stroke="#34495c" stroke-width="1" stroke-dasharray="5 7"/>`
694
+ : `<line x1="${marker.x + 24}" y1="${HEADER_HEIGHT}" x2="${marker.x + 24}" y2="${height - PADDING}" stroke="#34495c" stroke-width="1" stroke-dasharray="5 7"/>`;
695
+ const fontSize = Math.max(10, Math.min(32, (marker.style?.maxHeight ?? 24) * 0.75));
696
+ const measuredWidth = text.measure(String(marker.year), fontSize);
697
+ const alignment = marker.style?.format?.toLowerCase();
698
+ const labelX = alignment === 'center'
699
+ ? marker.x + ((marker.style?.maxWidth ?? measuredWidth) - measuredWidth) / 2
700
+ : alignment === 'right'
701
+ ? marker.x + (marker.style?.maxWidth ?? measuredWidth) - measuredWidth
702
+ : marker.x;
590
703
  const label = text.render(String(marker.year), {
591
- x: marker.x,
592
- y: marker.y + 22,
593
- fontSize: 18,
594
- fill: '#c7d4df',
704
+ x: labelX,
705
+ y: marker.y + fontSize,
706
+ fontSize,
707
+ fill: marker.style?.colour?.match(/^#[0-9a-fA-F]{6}$/u) ? marker.style.colour : '#dadada',
595
708
  weight: 700,
596
709
  });
597
- return `<g data-tech-year="${marker.year}" data-year-axis="${yearAxis ?? 'horizontal'}" data-source-path="${escapeXml(marker.sourcePath)}" data-source-line="${marker.location.start.line}">${guide}${label}</g>`;
710
+ return `<g data-tech-year="${marker.year}" data-year-axis="${yearAxis ?? 'horizontal'}" data-source-path="${escapeXml(marker.sourcePath)}" data-source-line="${marker.location.start.line}" data-font="${escapeXml(marker.style?.fontName ?? '')}" data-format="${escapeXml(marker.style?.format ?? '')}">${guide}${label}</g>`;
598
711
  });
599
712
  const edgeSvg = edges.flatMap((edge) => {
600
713
  const from = byId.get(edge.from);
601
714
  const to = byId.get(edge.to);
602
715
  if (from === undefined || to === undefined)
603
716
  return [];
604
- const x1 = from.x + from.width;
717
+ const x1 = folderView ? from.x + from.width / 2 : from.x + from.width;
605
718
  const y1 = from.y + from.height / 2;
606
- const x2 = to.x;
719
+ const x2 = folderView ? to.x + to.width / 2 : to.x;
607
720
  const y2 = to.y + to.height / 2;
608
721
  const middle = (x1 + x2) / 2;
609
- const colour = edge.kind.includes('exclusive') ? '#e27878' : '#8298ad';
722
+ const colour = edge.kind.includes('exclusive') ? '#a47942' : folderView ? '#70716e' : '#8298ad';
610
723
  const dash = edge.kind.includes('exclusive') ? ' stroke-dasharray="7 5"' : '';
724
+ if (folderView) {
725
+ const horizontal = Math.abs(x2 - x1) >= Math.abs(y2 - y1);
726
+ const route = horizontal
727
+ ? `M ${x1} ${y1} H ${middle} V ${y2} H ${x2}`
728
+ : `M ${x1} ${y1} V ${(y1 + y2) / 2} H ${x2} V ${y2}`;
729
+ return `<path d="${route}" fill="none" stroke="#242523" stroke-width="6"/><path d="${route}" fill="none" stroke="${colour}" stroke-width="3"${dash}><title>${escapeXml(edge.kind)}</title></path>`;
730
+ }
611
731
  return `<path d="M ${x1} ${y1} C ${middle} ${y1}, ${middle} ${y2}, ${x2} ${y2}" fill="none" stroke="${colour}" stroke-width="2" marker-end="url(#tech-arrow)"${dash}><title>${escapeXml(edge.kind)}</title></path>`;
612
732
  });
613
733
  const nodeSvg = positioned.map((node) => {
614
- const colour = colours(node.kind);
734
+ if (folderView && node.placement !== undefined) {
735
+ const preferredSprite = node.itemBackgroundSprite === undefined
736
+ ? undefined
737
+ : node.scenarioStatus === 'researched'
738
+ ? node.itemBackgroundSprite.replace('_unavailable_item_bg', '_researched_item_bg')
739
+ : node.scenarioStatus === 'structural_candidate'
740
+ ? node.itemBackgroundSprite.replace('_unavailable_item_bg', '_available_item_bg')
741
+ : node.itemBackgroundSprite;
742
+ const itemSkin = preferredSprite === undefined
743
+ ? undefined
744
+ : (iconDataUris[preferredSprite] ?? iconDataUris[node.itemBackgroundSprite]);
745
+ const icon = node.iconSprite === undefined ? undefined : iconDataUris[node.iconSprite];
746
+ const iconSize = node.layoutSize === 'small' ? 62 : 70;
747
+ const center = node.iconPosition ?? { x: node.width / 2, y: node.height / 2, centered: true };
748
+ const iconX = node.x + center.x - (center.centered ? iconSize / 2 : 0);
749
+ const iconY = node.y + center.y - (center.centered ? iconSize / 2 : 0);
750
+ const name = node.namePosition ?? { x: 3, y: -3, maxWidth: node.width - 6 };
751
+ const labelWidth = Math.min(name.maxWidth ?? node.width - 6, node.width - name.x - 3);
752
+ let fittedLabel = node.label;
753
+ while (fittedLabel.length > 1 && text.measure(fittedLabel, 12) > labelWidth)
754
+ fittedLabel = `${fittedLabel.slice(0, -2)}…`;
755
+ const label = node.layoutSize === 'small'
756
+ ? ''
757
+ : text.render(fittedLabel, {
758
+ x: node.x + name.x,
759
+ y: node.y + name.y + 15,
760
+ fontSize: 12,
761
+ fill: '#e8e7e1',
762
+ weight: 600,
763
+ });
764
+ const skin = itemSkin === undefined
765
+ ? `<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="3" fill="#3a3936" stroke="#8b8b86" stroke-width="1"/>`
766
+ : `<image data-item-background-sprite="${escapeXml(preferredSprite)}" href="${escapeXml(itemSkin)}" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" preserveAspectRatio="none"/>`;
767
+ const iconSvg = icon === undefined
768
+ ? ''
769
+ : `<image href="${escapeXml(icon)}" x="${iconX}" y="${iconY}" width="${iconSize}" height="${iconSize}" preserveAspectRatio="xMidYMid meet"/>`;
770
+ const designTeamSprite = node.designTeamIcon?.sprite;
771
+ const designTeamImage = designTeamSprite === undefined ? undefined : iconDataUris[designTeamSprite];
772
+ const designTeamSize = designTeamSprite === undefined ? undefined : spriteDimensions[designTeamSprite];
773
+ const designTeamSvg = designTeamImage === undefined || node.designTeamIcon === undefined
774
+ ? ''
775
+ : `<image data-design-team-icon="${escapeXml(designTeamSprite)}" href="${escapeXml(designTeamImage)}" x="${node.x + node.designTeamIcon.x}" y="${node.y + node.designTeamIcon.y}" width="${designTeamSize?.width ?? 20}" height="${designTeamSize?.height ?? 20}" preserveAspectRatio="xMinYMin meet"/>`;
776
+ const subTechnologySvg = (node.subTechnologies ?? []).flatMap(({ id, iconSprite, slot }) => {
777
+ if (slot === undefined)
778
+ return [];
779
+ const x = node.x + slot.position.x;
780
+ const y = node.y + slot.position.y;
781
+ const subIcon = iconSprite === undefined ? undefined : iconDataUris[iconSprite];
782
+ return [
783
+ `<g data-subtechnology-id="${escapeXml(id)}" data-slot-index="${slot.index}" data-icon-sprite="${escapeXml(iconSprite ?? '')}"><rect x="${x}" y="${y}" width="${slot.size.width}" height="${slot.size.height}" rx="3" fill="#3a3936" stroke="#8b8b86" stroke-width="1"/>${subIcon === undefined ? '' : `<image href="${escapeXml(subIcon)}" x="${x + 2}" y="${y + 2}" width="${Math.max(1, slot.size.width - 4)}" height="${Math.max(1, slot.size.height - 4)}" preserveAspectRatio="xMidYMid meet"/>`}<title>${escapeXml(id)}</title></g>`,
784
+ ];
785
+ });
786
+ return `<g data-node-id="${escapeXml(node.id)}"${node.scenarioStatus === undefined ? '' : ` data-scenario-status="${node.scenarioStatus}"`} data-layout-size="${node.layoutSize ?? 'unknown'}" data-icon-sprite="${escapeXml(node.iconSprite ?? '')}"${node.sourcePath === undefined ? '' : ` data-source-path="${escapeXml(node.sourcePath)}" data-source-line="${node.sourceLine ?? 1}"`}>${skin}${iconSvg}${label}${subTechnologySvg.join('')}${designTeamSvg}<title>${escapeXml(node.id)}</title></g>`;
787
+ }
788
+ const colour = node.scenarioStatus === 'researched'
789
+ ? { fill: '#214c38', stroke: '#79d29b' }
790
+ : node.scenarioStatus === 'exclusive_conflict'
791
+ ? { fill: '#503039', stroke: '#e27878' }
792
+ : node.scenarioStatus === 'missing_prerequisite'
793
+ ? { fill: '#423c29', stroke: '#e0b66b' }
794
+ : colours(node.kind);
615
795
  const small = node.layoutSize === 'small';
616
796
  const iconDataUri = node.iconSprite === undefined ? undefined : iconDataUris[node.iconSprite];
617
797
  const compactIcon = iconDataUri !== undefined && small && node.width <= SMALL_NODE_WIDTH;
@@ -651,7 +831,17 @@ function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris, year
651
831
  : `<image href="${escapeXml(iconDataUri)}" x="${node.x + (compactIcon ? 6 : 8)}" y="${node.y + (node.height - iconSize) / 2}" width="${iconSize}" height="${iconSize}" preserveAspectRatio="xMidYMid meet"/>`;
652
832
  const layoutAttribute = node.layoutSize === undefined ? '' : ` data-layout-size="${node.layoutSize}"`;
653
833
  const iconAttribute = node.iconSprite === undefined ? '' : ` data-icon-sprite="${escapeXml(node.iconSprite)}"`;
654
- return `<g data-node-id="${escapeXml(node.id)}"${layoutAttribute}${iconAttribute}${node.sourcePath === undefined ? '' : ` data-source-path="${escapeXml(node.sourcePath)}" data-source-line="${node.sourceLine ?? 1}"`}><rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="8" fill="${colour.fill}" stroke="${colour.stroke}" stroke-width="2"/><title>${escapeXml(`${node.id}${node.layoutSize === undefined ? '' : ` · ${node.layoutSize} layout`}${node.sourcePath === undefined ? '' : ` · ${node.sourcePath}:${node.sourceLine ?? 1}`}`)}</title>${iconSvg}${labelSvg}${subtitleSvg}</g>`;
834
+ const subTechnologySvg = (node.subTechnologies ?? []).flatMap(({ id, iconSprite, slot }) => {
835
+ if (slot === undefined)
836
+ return [];
837
+ const x = node.x + slot.position.x;
838
+ const y = node.y + slot.position.y;
839
+ const image = iconSprite === undefined ? undefined : iconDataUris[iconSprite];
840
+ return [
841
+ `<g data-subtechnology-id="${escapeXml(id)}" data-slot-index="${slot.index}" data-icon-sprite="${escapeXml(iconSprite ?? '')}"><rect x="${x}" y="${y}" width="${slot.size.width}" height="${slot.size.height}" rx="3" fill="#23364b" stroke="#91a9bd" stroke-width="1"/>${image === undefined ? '' : `<image href="${escapeXml(image)}" x="${x + 2}" y="${y + 2}" width="${Math.max(1, slot.size.width - 4)}" height="${Math.max(1, slot.size.height - 4)}" preserveAspectRatio="xMidYMid meet"/>`}<title>${escapeXml(id)}</title></g>`,
842
+ ];
843
+ });
844
+ return `<g data-node-id="${escapeXml(node.id)}"${node.scenarioStatus === undefined ? '' : ` data-scenario-status="${node.scenarioStatus}"`}${layoutAttribute}${iconAttribute}${node.sourcePath === undefined ? '' : ` data-source-path="${escapeXml(node.sourcePath)}" data-source-line="${node.sourceLine ?? 1}"`}><rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="8" fill="${colour.fill}" stroke="${colour.stroke}" stroke-width="2"/><title>${escapeXml(`${node.id}${node.layoutSize === undefined ? '' : ` · ${node.layoutSize} layout`}${node.sourcePath === undefined ? '' : ` · ${node.sourcePath}:${node.sourceLine ?? 1}`}`)}</title>${iconSvg}${labelSvg}${subtitleSvg}${subTechnologySvg.join('')}</g>`;
655
845
  });
656
846
  const title = text.render(titleText, {
657
847
  x: PADDING,
@@ -664,7 +854,7 @@ function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris, year
664
854
  return {
665
855
  width,
666
856
  height,
667
- svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(titleText)}"><defs><marker id="tech-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#8298ad"/></marker>${text.definitions()}</defs><rect width="100%" height="100%" fill="#0d1721"/>${title}${mode}<g>${yearGuideSvg.join('')}</g><g>${edgeSvg.join('')}</g><g>${nodeSvg.join('')}</g></svg>`,
857
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(titleText)}"><defs><marker id="tech-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#8298ad"/></marker>${panelPattern}${text.definitions()}</defs><rect width="100%" height="100%" fill="${panelPattern === '' ? (folderView ? '#171715' : '#0d1721') : 'url(#tech-folder-panel)'}"/><g>${backgroundSvg.join('')}</g>${folderView ? '' : title + mode}<g>${yearGuideSvg.join('')}</g><g>${edgeSvg.join('')}</g><g>${nodeSvg.join('')}</g></svg>`,
668
858
  };
669
859
  }
670
860
  function htmlFor(title, svg, json) {
@@ -674,20 +864,50 @@ export async function renderTechnologyGraph(graph, options) {
674
864
  options.signal?.throwIfAborted();
675
865
  const maximum = Math.max(1, Math.min(options.maxNodes ?? 600, MAX_RENDER_NODES));
676
866
  const selection = viewSelection(graph, options);
677
- const retained = selection.nodes.slice(0, maximum);
867
+ const retained = options.scenario === undefined
868
+ ? selection.nodes.slice(0, maximum)
869
+ : presentScenario(graph, selection.nodes.slice(0, maximum), options.scenario);
678
870
  const retainedIds = new Set(retained.map(({ id }) => id));
679
871
  const edges = selection.edges.filter(({ from, to }) => retainedIds.has(from) && retainedIds.has(to));
680
- const folderLayout = selection.sourceAccurate
681
- ? layoutFolder(retained, selection.yearMarkers)
872
+ const folderLayout = options.view === 'folder'
873
+ ? layoutFolder(retained, selection.yearMarkers, selection.backgrounds)
682
874
  : { nodes: layoutGenerated(retained, edges), yearMarkers: [] };
683
875
  const positioned = folderLayout.nodes;
684
876
  const iconDataUris = options.iconDataUris ?? {};
877
+ const spriteDimensions = options.spriteDimensions ?? {};
878
+ const firstSourceNode = positioned.length > 0 &&
879
+ positioned.every(({ placement }) => placement?.pixelX !== undefined && placement.pixelY !== undefined)
880
+ ? positioned[0]
881
+ : undefined;
882
+ const positionedBackgrounds = firstSourceNode === undefined
883
+ ? []
884
+ : selection.backgrounds.map((background) => ({
885
+ ...background,
886
+ x: background.position.x +
887
+ firstSourceNode.x -
888
+ firstSourceNode.placement.pixelX -
889
+ (firstSourceNode.placement.layoutOffsetX ?? 0),
890
+ y: background.position.y +
891
+ firstSourceNode.y -
892
+ firstSourceNode.placement.pixelY -
893
+ (firstSourceNode.placement.layoutOffsetY ?? 0),
894
+ }));
685
895
  const requestedIconSprites = [
686
- ...new Set(retained.flatMap(({ iconSprite }) => (iconSprite === undefined ? [] : [iconSprite]))),
896
+ ...new Set([
897
+ ...retained.flatMap(({ iconSprite, itemBackgroundSprite, designTeamIcon, subTechnologies }) => [
898
+ ...(iconSprite === undefined ? [] : [iconSprite]),
899
+ ...(designTeamIcon === undefined ? [] : [designTeamIcon.sprite]),
900
+ ...(itemBackgroundSprite === undefined
901
+ ? []
902
+ : itemBackgroundSprites(itemBackgroundSprite)),
903
+ ...(subTechnologies ?? []).flatMap((sub) => sub.iconSprite === undefined ? [] : [sub.iconSprite]),
904
+ ]),
905
+ ...selection.backgrounds.map(({ sprite }) => sprite),
906
+ ]),
687
907
  ].sort(compareCodeUnits);
688
908
  const renderedIconSprites = requestedIconSprites.filter((sprite) => iconDataUris[sprite] !== undefined);
689
909
  const unresolvedIconSprites = requestedIconSprites.filter((sprite) => iconDataUris[sprite] === undefined);
690
- const rendered = svgFor(selection.title, positioned, edges, selection.sourceAccurate, iconDataUris, folderLayout.yearMarkers, folderLayout.yearAxis);
910
+ const rendered = svgFor(selection.title, positioned, edges, selection.sourceAccurate, iconDataUris, spriteDimensions, folderLayout.yearMarkers, positionedBackgrounds, folderLayout.yearAxis, options.view === 'folder' && selection.sourceAccurate);
691
911
  const budget = options.budget ?? new RenderBudget();
692
912
  budget.reserve(rendered.width, rendered.height, `technology ${options.view} render`);
693
913
  budget.reserveRasterOperation(`technology-render:${graph.revision}:${options.view}:${sha256Bytes(rendered.svg)}`, `technology ${options.view} rasterization`);
@@ -699,6 +919,15 @@ export async function renderTechnologyGraph(graph, options) {
699
919
  options.signal?.throwIfAborted();
700
920
  const json = `${canonicalJson({
701
921
  ...selection.payload,
922
+ ...(options.scenario === undefined
923
+ ? {}
924
+ : {
925
+ scenarioPresentation: {
926
+ year: options.scenario.year,
927
+ researchedTechnologyIds: [...new Set(options.scenario.researchedTechnologyIds)].sort(compareCodeUnits),
928
+ interpretation: 'Structural prerequisites and exclusivity only; dynamic country conditions remain unresolved',
929
+ },
930
+ }),
702
931
  nodes: retained,
703
932
  edges,
704
933
  omittedNodeCount: Math.max(0, selection.nodes.length - retained.length),
@@ -712,6 +941,21 @@ export async function renderTechnologyGraph(graph, options) {
712
941
  axis: folderLayout.yearAxis ?? null,
713
942
  markerCount: folderLayout.yearMarkers.length,
714
943
  },
944
+ backgrounds: selection.backgrounds.map((background) => ({
945
+ id: background.id,
946
+ folderId: background.folderId,
947
+ name: background.name,
948
+ sprite: background.sprite,
949
+ sourcePath: background.sourcePath,
950
+ sourceLine: background.location.start.line,
951
+ placement: positionedBackgrounds.find(({ id }) => id === background.id) ?? null,
952
+ status: positionedBackgrounds.some(({ id }) => id === background.id) &&
953
+ iconDataUris[background.sprite] !== undefined &&
954
+ (background.size.width ?? spriteDimensions[background.sprite]?.width) !== undefined &&
955
+ (background.size.height ?? spriteDimensions[background.sprite]?.height) !== undefined
956
+ ? 'rendered'
957
+ : 'unresolved',
958
+ })),
715
959
  })}\n`;
716
960
  const html = options.includeHtml === true ? htmlFor(selection.title, rendered.svg, json) : undefined;
717
961
  return {
@@ -740,9 +984,20 @@ export async function renderTechnologyGraph(graph, options) {
740
984
  /** Return only the sprite names that the bounded render selection will display. */
741
985
  export function technologyRenderIconSprites(graph, options) {
742
986
  const maximum = Math.max(1, Math.min(options.maxNodes ?? 600, MAX_RENDER_NODES));
987
+ const selection = viewSelection(graph, options);
743
988
  return [
744
- ...new Set(viewSelection(graph, options)
745
- .nodes.slice(0, maximum)
746
- .flatMap(({ iconSprite }) => (iconSprite === undefined ? [] : [iconSprite]))),
989
+ ...new Set([
990
+ ...selection.nodes
991
+ .slice(0, maximum)
992
+ .flatMap(({ iconSprite, itemBackgroundSprite, designTeamIcon, subTechnologies }) => [
993
+ ...(iconSprite === undefined ? [] : [iconSprite]),
994
+ ...(designTeamIcon === undefined ? [] : [designTeamIcon.sprite]),
995
+ ...(itemBackgroundSprite === undefined
996
+ ? []
997
+ : itemBackgroundSprites(itemBackgroundSprite)),
998
+ ...(subTechnologies ?? []).flatMap((sub) => sub.iconSprite === undefined ? [] : [sub.iconSprite]),
999
+ ]),
1000
+ ...selection.backgrounds.map(({ sprite }) => sprite),
1001
+ ]),
747
1002
  ].sort(compareCodeUnits);
748
1003
  }
@@ -142,11 +142,19 @@ async function resolveTechnologyIconDataUris(cached, requestedSprites, budget, s
142
142
  const icons = [
143
143
  ...cached.graph.technologies.map(({ icon }) => icon),
144
144
  ...cached.graph.doctrineDefinitions.flatMap(({ icon }) => icon ?? []),
145
+ ...(cached.graph.backgrounds ?? []).map(({ sprite }) => ({
146
+ sprite,
147
+ spritePath: cached.snapshot.index.find('sprite', sprite)?.path,
148
+ })),
149
+ ...[...requestedSprites].map((sprite) => ({
150
+ sprite,
151
+ spritePath: cached.snapshot.index.find('sprite', sprite)?.path,
152
+ })),
145
153
  ].filter(({ sprite }) => requestedSprites.has(sprite));
146
154
  const spriteSourcePaths = new Set(icons.flatMap(({ spritePath }) => (spritePath === undefined ? [] : [spritePath])));
147
155
  const spriteFiles = cached.snapshot.files.filter(({ displayPath }) => spriteSourcePaths.has(displayPath));
148
156
  if (spriteFiles.length === 0)
149
- return {};
157
+ return { dataUris: {}, dimensions: {} };
150
158
  const graph = buildGuiSourceGraph(spriteFiles, cached.snapshot.index);
151
159
  const files = new Map();
152
160
  for (const file of [...spriteFiles, ...cached.assetFiles])
@@ -158,6 +166,7 @@ async function resolveTechnologyIconDataUris(cached, requestedSprites, budget, s
158
166
  unique.set(icon.sprite, icon);
159
167
  const entries = [...unique.entries()].sort(([left], [right]) => compareCodeUnits(left, right));
160
168
  const dataUris = {};
169
+ const dimensions = {};
161
170
  let cursor = 0;
162
171
  await Promise.all(Array.from({ length: Math.min(8, entries.length) }, async () => {
163
172
  while (cursor < entries.length) {
@@ -171,11 +180,25 @@ async function resolveTechnologyIconDataUris(cached, requestedSprites, budget, s
171
180
  if (sprite === undefined)
172
181
  continue;
173
182
  const frame = await catalog.loadSpriteFrame(sprite, 0);
174
- if (frame?.supported === true && frame.dataUri !== undefined)
183
+ if (frame?.supported === true && frame.dataUri !== undefined) {
175
184
  dataUris[spriteName] = frame.dataUri;
185
+ dimensions[spriteName] = {
186
+ width: frame.width,
187
+ height: frame.height,
188
+ ...(sprite.borderSize?.width === undefined
189
+ ? {}
190
+ : { borderWidth: sprite.borderSize.width }),
191
+ ...(sprite.borderSize?.height === undefined
192
+ ? {}
193
+ : { borderHeight: sprite.borderSize.height }),
194
+ };
195
+ }
176
196
  }
177
197
  }));
178
- return Object.fromEntries(Object.entries(dataUris).sort(([left], [right]) => compareCodeUnits(left, right)));
198
+ return {
199
+ dataUris: Object.fromEntries(Object.entries(dataUris).sort(([left], [right]) => compareCodeUnits(left, right))),
200
+ dimensions: Object.fromEntries(Object.entries(dimensions).sort(([left], [right]) => compareCodeUnits(left, right))),
201
+ };
179
202
  }
180
203
  function rememberGraph(state, graph) {
181
204
  const history = state.history.get(graph.workspaceId) ?? new Map();
@@ -450,17 +473,37 @@ export class TechnologyTreeViewer {
450
473
  const generation = this.engine.generation(workspaceId);
451
474
  const cached = this.#state.current.get(workspaceId);
452
475
  const snapshot = await scanTechnologySources(this.engine, workspaceId, workspace, options.principal, options.signal);
453
- const analysisMode = 'full';
476
+ const analysisMode = workspace.roots.some(({ kind }) => kind === 'game') || snapshot.files.length > 1_000
477
+ ? 'focused'
478
+ : undefined;
454
479
  const unchangedSource = cached?.generation === generation && cached.snapshot.revision === snapshot.revision;
455
480
  const preliminary = unchangedSource
456
481
  ? cached.graph
457
482
  : await buildTechnologyGraphAsync(snapshot, {
458
483
  workspaceIdentity: workspace.workspaceIdentity,
459
484
  cache: this.#state.fragments,
460
- analysisMode,
485
+ ...(analysisMode === undefined ? {} : { analysisMode }),
461
486
  ...(options.signal === undefined ? {} : { signal: options.signal }),
462
487
  });
463
- const patterns = technologyAssetPatterns(preliminary);
488
+ const guiSprites = [
489
+ ...preliminary.itemLayouts.flatMap(({ designTeamIcon }) => designTeamIcon === undefined ? [] : [designTeamIcon.sprite]),
490
+ ...preliminary.itemLayouts.flatMap(({ backgroundSprite }) => backgroundSprite === undefined
491
+ ? []
492
+ : [
493
+ backgroundSprite,
494
+ backgroundSprite.replace('_unavailable_item_bg', '_available_item_bg'),
495
+ backgroundSprite.replace('_unavailable_item_bg', '_researched_item_bg'),
496
+ ]),
497
+ ...(preliminary.backgrounds ?? []).map(({ sprite }) => sprite),
498
+ ];
499
+ const guiTextures = guiSprites.flatMap((sprite) => {
500
+ const texture = snapshot.index.find('sprite', sprite)?.metadata.texture;
501
+ if (typeof texture !== 'string')
502
+ return [];
503
+ const normalized = texture.replaceAll('\\', '/').replace(/\/{2,}/gu, '/');
504
+ return normalized.includes('..') || /[[\]{}*?!]/u.test(normalized) ? [] : [normalized];
505
+ });
506
+ const patterns = [...new Set([...technologyAssetPatterns(preliminary), ...guiTextures])].sort(compareCodeUnits);
464
507
  const assetFiles = patterns.length === 0
465
508
  ? []
466
509
  : await this.engine.scanner.scan(workspace, {
@@ -479,7 +522,7 @@ export class TechnologyTreeViewer {
479
522
  workspaceIdentity: workspace.workspaceIdentity,
480
523
  cache: this.#state.fragments,
481
524
  assetFiles,
482
- analysisMode,
525
+ ...(analysisMode === undefined ? {} : { analysisMode }),
483
526
  ...(options.signal === undefined ? {} : { signal: options.signal }),
484
527
  });
485
528
  this.#state.current.set(workspaceId, { generation, snapshot, assetFiles, graph });
@@ -620,6 +663,7 @@ export class TechnologyTreeViewer {
620
663
  maxNodes: input.maxNodes ?? 1_000,
621
664
  includeHtml: input.includeHtml ?? false,
622
665
  budget,
666
+ ...(input.scenario === undefined ? {} : { scenario: input.scenario }),
623
667
  ...(input.folderId === undefined ? {} : { folderId: input.folderId }),
624
668
  ...(input.technologyId === undefined ? {} : { technologyId: input.technologyId }),
625
669
  ...(input.categoryId === undefined ? {} : { categoryId: input.categoryId }),
@@ -639,8 +683,9 @@ export class TechnologyTreeViewer {
639
683
  maxNodes: 1_000,
640
684
  })),
641
685
  ]);
642
- const iconDataUris = await resolveTechnologyIconDataUris(cached, requestedIconSprites, budget, input.signal);
686
+ const { dataUris: iconDataUris, dimensions: spriteDimensions } = await resolveTechnologyIconDataUris(cached, requestedIconSprites, budget, input.signal);
643
687
  renderOptions.iconDataUris = iconDataUris;
688
+ renderOptions.spriteDimensions = spriteDimensions;
644
689
  const render = await renderTechnologyGraph(graph, renderOptions);
645
690
  const focused = [];
646
691
  for (const folder of focusedFolders) {
@@ -651,7 +696,9 @@ export class TechnologyTreeViewer {
651
696
  maxNodes: 1_000,
652
697
  includeHtml: false,
653
698
  iconDataUris,
699
+ spriteDimensions,
654
700
  budget,
701
+ ...(input.scenario === undefined ? {} : { scenario: input.scenario }),
655
702
  ...(input.signal === undefined ? {} : { signal: input.signal }),
656
703
  }));
657
704
  }