hoi4-agent-tools 2.5.2 → 2.5.3

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 (42) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/hoi4_agent_tools/core/index.js +22 -6
  3. package/dist/hoi4_agent_tools/focus/compact.js +103 -12
  4. package/dist/hoi4_agent_tools/focus/continuous-render.js +4 -10
  5. package/dist/hoi4_agent_tools/focus/layout.js +15 -7
  6. package/dist/hoi4_agent_tools/focus/render.js +2 -8
  7. package/dist/hoi4_agent_tools/gui/dds.js +5 -2
  8. package/dist/hoi4_agent_tools/gui/layout.js +1 -1
  9. package/dist/hoi4_agent_tools/gui/renderer.js +4 -16
  10. package/dist/hoi4_agent_tools/gui/source-graph.js +9 -8
  11. package/dist/hoi4_agent_tools/gui/studio.js +58 -4
  12. package/dist/hoi4_agent_tools/map/render.js +1 -1
  13. package/dist/hoi4_agent_tools/mcp/transports/http.js +4 -5
  14. package/dist/hoi4_agent_tools/mcp/transports/protocol-gate.js +7 -7
  15. package/dist/hoi4_agent_tools/technology/render.js +65 -19
  16. package/dist/hoi4_agent_tools/technology/service.js +67 -3
  17. package/dist/hoi4_agent_tools/version.js +1 -1
  18. package/docs/focus.md +1 -1
  19. package/docs/gui.md +2 -0
  20. package/docs/technology.md +1 -1
  21. package/package.json +3 -2
  22. package/schemas/configuration.schema.json +1 -1
  23. package/schemas/continuous-focus-palette.schema.json +1 -1
  24. package/schemas/custom-weighted-pool.schema.json +1 -1
  25. package/schemas/event-feature-manifest.schema.json +1 -1
  26. package/schemas/focus-plan.schema.json +1 -1
  27. package/schemas/focus-planning-sidecar.schema.json +1 -1
  28. package/schemas/gui-animation-source.schema.json +1 -1
  29. package/schemas/gui-helper.schema.json +1 -1
  30. package/schemas/gui-scenario.schema.json +1 -1
  31. package/schemas/map-operation.schema.json +1 -1
  32. package/schemas/operation-result.schema.json +1 -1
  33. package/schemas/probability-analysis-result.schema.json +1 -1
  34. package/schemas/probability-compare-input.schema.json +1 -1
  35. package/schemas/probability-evaluate-input.schema.json +1 -1
  36. package/schemas/probability-inspect-input.schema.json +1 -1
  37. package/schemas/probability-render-input.schema.json +1 -1
  38. package/schemas/probability-scenario-set.schema.json +1 -1
  39. package/schemas/probability-sequence-input.schema.json +1 -1
  40. package/schemas/probability-simulate-input.schema.json +1 -1
  41. package/schemas/probability-sweep-input.schema.json +1 -1
  42. package/server.json +2 -2
@@ -4,9 +4,8 @@ import { TextDecoder } from 'node:util';
4
4
  import express, {} from 'express';
5
5
  import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';
6
6
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
- import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
7
+ import { isInitializeRequest, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { canonicalJson } from '../../core/canonical.js';
9
- import { MCP_PROTOCOL_VERSION } from '../../version.js';
10
9
  import { authenticate, bearerChallenge, HttpAuthError, protectedResourceMetadata, validateHttpDeployment, } from '../security/auth.js';
11
10
  import { BoundedEventStore, SharedEventStoreBudget } from './event-store.js';
12
11
  import { FinalProtocolTransport } from './protocol-gate.js';
@@ -296,15 +295,15 @@ function requireParsedMcpJsonBody(request, response, next) {
296
295
  }
297
296
  function rejectInvalidProtocolHeader(request, response, required) {
298
297
  const value = request.headers['mcp-protocol-version'];
299
- if (value === MCP_PROTOCOL_VERSION)
298
+ if (typeof value === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(value))
300
299
  return false;
301
300
  if (value === undefined && !required)
302
301
  return false;
303
302
  if (value === undefined) {
304
- jsonRpcError(response, 400, `Missing MCP-Protocol-Version header; this server requires ${MCP_PROTOCOL_VERSION}`);
303
+ jsonRpcError(response, 400, `Missing MCP-Protocol-Version header; this server supports ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}`);
305
304
  return true;
306
305
  }
307
- jsonRpcError(response, 400, `Unsupported MCP protocol version; this server requires ${MCP_PROTOCOL_VERSION}`);
306
+ jsonRpcError(response, 400, `Unsupported MCP protocol version; this server supports ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}`);
308
307
  return true;
309
308
  }
310
309
  function sendAuthError(response, configuration, error) {
@@ -1,19 +1,19 @@
1
- import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
2
- import { MCP_PROTOCOL_VERSION } from '../../version.js';
1
+ import { isInitializeRequest, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/sdk/types.js';
3
2
  const unsupportedVersionSentinel = 'hoi4-agent-tools-final-only';
4
3
  function gateInitializeVersion(message) {
5
- if (!isInitializeRequest(message) || message.params.protocolVersion === MCP_PROTOCOL_VERSION) {
4
+ if (!isInitializeRequest(message) ||
5
+ SUPPORTED_PROTOCOL_VERSIONS.includes(message.params.protocolVersion)) {
6
6
  return message;
7
7
  }
8
- // SDK v1 negotiates every historical revision it knows. This server uses current-final
9
- // resource-link and structured-output semantics, so force the SDK's documented fallback
10
- // response to the sole production revision instead of claiming unimplemented feature gates.
8
+ // Unknown revisions cannot be negotiated. Rewrite them to an unsupported sentinel so the
9
+ // SDK applies its documented fallback response (the latest supported revision) instead of
10
+ // claiming unimplemented feature gates.
11
11
  return {
12
12
  ...message,
13
13
  params: { ...message.params, protocolVersion: unsupportedVersionSentinel },
14
14
  };
15
15
  }
16
- /** Restrict a concrete product transport to the documented final MCP revision. */
16
+ /** Let the SDK negotiate any revision it supports and fall back to the latest for unknown ones. */
17
17
  export class FinalProtocolTransport {
18
18
  inner;
19
19
  onclose = () => undefined;
@@ -47,6 +47,7 @@ function technologyNode(graph, technologyId, placement) {
47
47
  sourcePath: technology.source.path,
48
48
  sourceLine: technology.source.location.start.line,
49
49
  layoutSize: technology.layoutSize,
50
+ iconSprite: technology.icon.sprite,
50
51
  ...(placement === undefined ? {} : { placement }),
51
52
  };
52
53
  }
@@ -152,6 +153,7 @@ function viewSelection(graph, options) {
152
153
  kind: 'doctrine',
153
154
  sourcePath: definition.source.path,
154
155
  sourceLine: definition.source.location.start.line,
156
+ ...(definition.icon === undefined ? {} : { iconSprite: definition.icon.sprite }),
155
157
  });
156
158
  for (const definition of graph.doctrineDefinitions) {
157
159
  const from = `doctrine:${definition.kind}:${definition.id}`;
@@ -203,6 +205,7 @@ function viewSelection(graph, options) {
203
205
  kind: 'doctrine',
204
206
  sourcePath: doctrine.source.path,
205
207
  sourceLine: doctrine.source.location.start.line,
208
+ ...(doctrine.icon === undefined ? {} : { iconSprite: doctrine.icon.sprite }),
206
209
  });
207
210
  for (const targetId of doctrine.exclusiveIds) {
208
211
  const target = graph.doctrineDefinitions.find(({ id }) => id === targetId);
@@ -215,7 +218,11 @@ function viewSelection(graph, options) {
215
218
  kind: target === undefined ? 'unresolved' : 'doctrine',
216
219
  ...(target === undefined
217
220
  ? {}
218
- : { sourcePath: target.source.path, sourceLine: target.source.location.start.line }),
221
+ : {
222
+ sourcePath: target.source.path,
223
+ sourceLine: target.source.location.start.line,
224
+ ...(target.icon === undefined ? {} : { iconSprite: target.icon.sprite }),
225
+ }),
219
226
  });
220
227
  edges.push({ id: `${from}:xor:${to}`, from, to, kind: 'exclusive' });
221
228
  }
@@ -537,7 +544,7 @@ function colours(kind) {
537
544
  removed: { fill: '#4a2828', stroke: '#ef7d7d' },
538
545
  }[kind];
539
546
  }
540
- function svgFor(titleText, positioned, edges, sourceAccurate) {
547
+ function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris) {
541
548
  const maximumX = Math.max(PADDING + LARGE_NODE_WIDTH, ...positioned.map(({ x, width }) => x + width));
542
549
  const maximumY = Math.max(HEADER_HEIGHT + LARGE_NODE_HEIGHT, ...positioned.map(({ y, height }) => y + height));
543
550
  const width = Math.ceil(maximumX + PADDING);
@@ -566,28 +573,45 @@ function svgFor(titleText, positioned, edges, sourceAccurate) {
566
573
  const nodeSvg = positioned.map((node) => {
567
574
  const colour = colours(node.kind);
568
575
  const small = node.layoutSize === 'small';
576
+ const iconDataUri = node.iconSprite === undefined ? undefined : iconDataUris[node.iconSprite];
577
+ const compactIcon = iconDataUri !== undefined && small && node.width <= SMALL_NODE_WIDTH;
578
+ const iconSize = iconDataUri === undefined
579
+ ? 0
580
+ : compactIcon
581
+ ? Math.max(1, Math.min(node.width - 12, node.height - 12))
582
+ : Math.max(1, Math.min(64, node.height - 16, Math.floor(node.width * 0.36)));
583
+ const textInset = iconDataUri === undefined ? (small ? 6 : 12) : iconSize + 18;
584
+ const availableTextWidth = Math.max(1, node.width - textInset - (small ? 6 : 12));
569
585
  const labelLimit = small ? 12 : 34;
570
586
  const label = node.label.length > labelLimit
571
587
  ? `${node.label.slice(0, Math.max(1, labelLimit - 1))}…`
572
588
  : node.label;
573
589
  const subtitle = node.subtitle.length > 48 ? `${node.subtitle.slice(0, 45)}…` : node.subtitle;
574
- const labelSvg = text.render(label, {
575
- x: node.x + (small ? 6 : 12),
576
- y: node.y + (small ? 27 : 30),
577
- fontSize: small ? 9 : 15,
578
- fill: '#f1f5f8',
579
- weight: 600,
580
- targetWidth: Math.min(node.width - (small ? 12 : 24), Math.max(1, text.measure(label, small ? 9 : 15))),
581
- });
582
- const subtitleSvg = text.render(small ? 'SMALL' : subtitle, {
583
- x: node.x + (small ? 6 : 12),
584
- y: node.y + (small ? 51 : 58),
585
- fontSize: small ? 8 : 11,
586
- fill: '#b8c4ce',
587
- targetWidth: Math.min(node.width - (small ? 12 : 24), Math.max(1, text.measure(small ? 'SMALL' : subtitle, small ? 8 : 11))),
588
- });
590
+ const labelSvg = compactIcon
591
+ ? ''
592
+ : text.render(label, {
593
+ x: node.x + textInset,
594
+ y: node.y + (small ? 27 : 30),
595
+ fontSize: small ? 9 : 15,
596
+ fill: '#f1f5f8',
597
+ weight: 600,
598
+ targetWidth: Math.min(availableTextWidth, Math.max(1, text.measure(label, small ? 9 : 15))),
599
+ });
600
+ const subtitleSvg = compactIcon
601
+ ? ''
602
+ : text.render(small ? 'SMALL' : subtitle, {
603
+ x: node.x + textInset,
604
+ y: node.y + (small ? 51 : 58),
605
+ fontSize: small ? 8 : 11,
606
+ fill: '#b8c4ce',
607
+ targetWidth: Math.min(availableTextWidth, Math.max(1, text.measure(small ? 'SMALL' : subtitle, small ? 8 : 11))),
608
+ });
609
+ const iconSvg = iconDataUri === undefined
610
+ ? ''
611
+ : `<image href="${escapeXml(iconDataUri)}" x="${node.x + (compactIcon ? 6 : 8)}" y="${node.y + (node.height - iconSize) / 2}" width="${iconSize}" height="${iconSize}" preserveAspectRatio="xMidYMid meet"/>`;
589
612
  const layoutAttribute = node.layoutSize === undefined ? '' : ` data-layout-size="${node.layoutSize}"`;
590
- return `<g data-node-id="${escapeXml(node.id)}"${layoutAttribute}${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>${labelSvg}${subtitleSvg}</g>`;
613
+ const iconAttribute = node.iconSprite === undefined ? '' : ` data-icon-sprite="${escapeXml(node.iconSprite)}"`;
614
+ 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>`;
591
615
  });
592
616
  const title = text.render(titleText, {
593
617
  x: PADDING,
@@ -616,7 +640,13 @@ export async function renderTechnologyGraph(graph, options) {
616
640
  const positioned = selection.sourceAccurate
617
641
  ? layoutFolder(retained)
618
642
  : layoutGenerated(retained, edges);
619
- const rendered = svgFor(selection.title, positioned, edges, selection.sourceAccurate);
643
+ const iconDataUris = options.iconDataUris ?? {};
644
+ const requestedIconSprites = [
645
+ ...new Set(retained.flatMap(({ iconSprite }) => (iconSprite === undefined ? [] : [iconSprite]))),
646
+ ].sort(compareCodeUnits);
647
+ const renderedIconSprites = requestedIconSprites.filter((sprite) => iconDataUris[sprite] !== undefined);
648
+ const unresolvedIconSprites = requestedIconSprites.filter((sprite) => iconDataUris[sprite] === undefined);
649
+ const rendered = svgFor(selection.title, positioned, edges, selection.sourceAccurate, iconDataUris);
620
650
  const budget = options.budget ?? new RenderBudget();
621
651
  budget.reserve(rendered.width, rendered.height, `technology ${options.view} render`);
622
652
  budget.reserveRasterOperation(`technology-render:${graph.revision}:${options.view}:${sha256Bytes(rendered.svg)}`, `technology ${options.view} rasterization`);
@@ -631,6 +661,11 @@ export async function renderTechnologyGraph(graph, options) {
631
661
  nodes: retained,
632
662
  edges,
633
663
  omittedNodeCount: Math.max(0, selection.nodes.length - retained.length),
664
+ iconCoverage: {
665
+ requested: requestedIconSprites.length,
666
+ rendered: renderedIconSprites.length,
667
+ unresolvedSprites: unresolvedIconSprites,
668
+ },
634
669
  render: { width: rendered.width, height: rendered.height },
635
670
  })}\n`;
636
671
  const html = options.includeHtml === true ? htmlFor(selection.title, rendered.svg, json) : undefined;
@@ -639,6 +674,8 @@ export async function renderTechnologyGraph(graph, options) {
639
674
  graphRevision: graph.revision,
640
675
  selectedIds: retained.map(({ id }) => id),
641
676
  omittedNodeCount: Math.max(0, selection.nodes.length - retained.length),
677
+ renderedIconCount: renderedIconSprites.length,
678
+ unresolvedIconSprites,
642
679
  sourceAccurate: selection.sourceAccurate,
643
680
  generatedAnalysisLayout: !selection.sourceAccurate,
644
681
  width: rendered.width,
@@ -655,3 +692,12 @@ export async function renderTechnologyGraph(graph, options) {
655
692
  },
656
693
  };
657
694
  }
695
+ /** Return only the sprite names that the bounded render selection will display. */
696
+ export function technologyRenderIconSprites(graph, options) {
697
+ const maximum = Math.max(1, Math.min(options.maxNodes ?? 600, MAX_RENDER_NODES));
698
+ return [
699
+ ...new Set(viewSelection(graph, options)
700
+ .nodes.slice(0, maximum)
701
+ .flatMap(({ iconSprite }) => (iconSprite === undefined ? [] : [iconSprite]))),
702
+ ].sort(compareCodeUnits);
703
+ }
@@ -6,10 +6,12 @@ import { ServiceError } from '../core/result.js';
6
6
  import { SOURCE_MAX_BYTES } from '../core/source/index.js';
7
7
  import { isPortablePathSegment } from '../core/workspace.js';
8
8
  import { PACKAGE_VERSION } from '../version.js';
9
+ import { GuiAssetCatalog } from '../gui/assets.js';
10
+ import { buildGuiSourceGraph } from '../gui/source-graph.js';
9
11
  import { compareTechnologyGraphs } from './compare.js';
10
12
  import { buildTechnologyGraph, technologyAssetPatterns } from './graph.js';
11
13
  import { analyzeTechnologyImpact, discoverTechnologyFolders, explainTechnology, inspectTechnologyUnlocks, lintTechnologyGraph, technologyBonusCoverage, technologyScanReport, traceTechnology, } from './queries.js';
12
- import { renderTechnologyGraph, } from './render.js';
14
+ import { renderTechnologyGraph, technologyRenderIconSprites, } from './render.js';
13
15
  const FRAGMENT_CACHE_ENTRIES = 8_192;
14
16
  const FRAGMENT_CACHE_BYTES = 134_217_728;
15
17
  const PROPOSED_SOURCE_FILES = 128;
@@ -80,10 +82,54 @@ function technologyScanPatterns(workspace) {
80
82
  'history/countries/**/*.txt',
81
83
  ...under(roots.focus, '**/*.txt'),
82
84
  ...under(roots.interface, '**/*.gui'),
85
+ ...under(roots.interface, '**/*.gfx'),
86
+ ...under(roots.gfx, '**/*.gfx'),
83
87
  ...under(roots.localisation, 'english/**/*.{yml,yaml}'),
84
88
  ...under(roots.localisation, '*.{yml,yaml}'),
85
89
  ].sort(compareCodeUnits);
86
90
  }
91
+ function activeTechnologySprite(spriteName, spritePath, sprites) {
92
+ return sprites.find((sprite) => sprite.name === spriteName && (spritePath === undefined || sprite.sourcePath === spritePath));
93
+ }
94
+ async function resolveTechnologyIconDataUris(cached, requestedSprites, budget, signal) {
95
+ const icons = [
96
+ ...cached.graph.technologies.map(({ icon }) => icon),
97
+ ...cached.graph.doctrineDefinitions.flatMap(({ icon }) => icon ?? []),
98
+ ].filter(({ sprite }) => requestedSprites.has(sprite));
99
+ const spriteSourcePaths = new Set(icons.flatMap(({ spritePath }) => (spritePath === undefined ? [] : [spritePath])));
100
+ const spriteFiles = cached.snapshot.files.filter(({ displayPath }) => spriteSourcePaths.has(displayPath));
101
+ if (spriteFiles.length === 0)
102
+ return {};
103
+ const graph = buildGuiSourceGraph(spriteFiles, cached.snapshot.index);
104
+ const files = new Map();
105
+ for (const file of [...spriteFiles, ...cached.assetFiles])
106
+ files.set(file.displayPath, file);
107
+ const catalog = new GuiAssetCatalog(graph, [...files.values()], budget);
108
+ const unique = new Map();
109
+ for (const icon of icons)
110
+ if (!unique.has(icon.sprite))
111
+ unique.set(icon.sprite, icon);
112
+ const entries = [...unique.entries()].sort(([left], [right]) => compareCodeUnits(left, right));
113
+ const dataUris = {};
114
+ let cursor = 0;
115
+ await Promise.all(Array.from({ length: Math.min(8, entries.length) }, async () => {
116
+ while (cursor < entries.length) {
117
+ signal?.throwIfAborted();
118
+ const entry = entries[cursor];
119
+ cursor += 1;
120
+ if (entry === undefined)
121
+ continue;
122
+ const [spriteName, icon] = entry;
123
+ const sprite = activeTechnologySprite(spriteName, icon.spritePath, graph.sprites);
124
+ if (sprite === undefined)
125
+ continue;
126
+ const frame = await catalog.loadSpriteFrame(sprite, 0);
127
+ if (frame?.supported === true && frame.dataUri !== undefined)
128
+ dataUris[spriteName] = frame.dataUri;
129
+ }
130
+ }));
131
+ return Object.fromEntries(Object.entries(dataUris).sort(([left], [right]) => compareCodeUnits(left, right)));
132
+ }
87
133
  function technologyAnalysisMode(workspace, snapshot) {
88
134
  return workspace.gameRoot !== undefined || snapshot.files.length > FOCUSED_GRAPH_FILE_THRESHOLD
89
135
  ? 'focused'
@@ -462,6 +508,9 @@ export class TechnologyTreeViewer {
462
508
  ...(input.signal === undefined ? {} : { signal: input.signal }),
463
509
  });
464
510
  const budget = new RenderBudget();
511
+ const cached = this.#state.current.get(input.workspaceId);
512
+ if (cached?.graph.revision !== graph.revision)
513
+ throw new ServiceError('TECH_RENDER_SOURCE_MISSING', 'Technology render source inventory is unavailable for the current graph');
465
514
  const renderOptions = {
466
515
  view: input.view,
467
516
  maxNodes: input.maxNodes ?? 1_000,
@@ -473,13 +522,23 @@ export class TechnologyTreeViewer {
473
522
  ...(input.targetId === undefined ? {} : { targetId: input.targetId }),
474
523
  ...(input.signal === undefined ? {} : { signal: input.signal }),
475
524
  };
476
- const render = await renderTechnologyGraph(graph, renderOptions);
477
- const focused = [];
478
525
  const focusedFolders = input.view === 'dependencies' && input.folderId === undefined
479
526
  ? graph.folders
480
527
  .filter((folder) => graph.placements.some(({ folderId }) => folderId === folder.id))
481
528
  .slice(0, FOCUSED_RENDER_LIMIT)
482
529
  : [];
530
+ const requestedIconSprites = new Set([
531
+ ...technologyRenderIconSprites(graph, renderOptions),
532
+ ...focusedFolders.flatMap(({ id }) => technologyRenderIconSprites(graph, {
533
+ view: 'folder',
534
+ folderId: id,
535
+ maxNodes: 1_000,
536
+ })),
537
+ ]);
538
+ const iconDataUris = await resolveTechnologyIconDataUris(cached, requestedIconSprites, budget, input.signal);
539
+ renderOptions.iconDataUris = iconDataUris;
540
+ const render = await renderTechnologyGraph(graph, renderOptions);
541
+ const focused = [];
483
542
  for (const folder of focusedFolders) {
484
543
  input.signal?.throwIfAborted();
485
544
  focused.push(await renderTechnologyGraph(graph, {
@@ -487,6 +546,7 @@ export class TechnologyTreeViewer {
487
546
  folderId: folder.id,
488
547
  maxNodes: 1_000,
489
548
  includeHtml: false,
549
+ iconDataUris,
490
550
  budget,
491
551
  ...(input.signal === undefined ? {} : { signal: input.signal }),
492
552
  }));
@@ -516,6 +576,8 @@ export class TechnologyTreeViewer {
516
576
  omittedNodeCount: render.omittedNodeCount,
517
577
  sourceAccurate: render.sourceAccurate,
518
578
  generatedAnalysisLayout: render.generatedAnalysisLayout,
579
+ renderedIconCount: render.renderedIconCount,
580
+ unresolvedIconSprites: render.unresolvedIconSprites,
519
581
  hashes: render.hashes,
520
582
  },
521
583
  focusedFolders: focused.map((bundle, index) => ({
@@ -523,6 +585,8 @@ export class TechnologyTreeViewer {
523
585
  folderId: focusedFolders[index]?.id,
524
586
  selectedIds: bundle.selectedIds,
525
587
  omittedNodeCount: bundle.omittedNodeCount,
588
+ renderedIconCount: bundle.renderedIconCount,
589
+ unresolvedIconSprites: bundle.unresolvedIconSprites,
526
590
  hashes: bundle.hashes,
527
591
  })),
528
592
  focusedFolderCoverage: {
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '2.5.2';
3
+ export const PACKAGE_VERSION = '2.5.3';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
package/docs/focus.md CHANGED
@@ -30,7 +30,7 @@ For a layout-only cleanup of an existing national tree, call `hoi4.focus_rewrite
30
30
 
31
31
  Compact mode imports the tree, resets authored presentation coordinates and lane bounds, and performs a deterministic reflow in the same rewrite call. It keeps prerequisites, mutual exclusions, availability, bypasses, rewards, icons, localisation, AI behavior, and route/lane membership unchanged.
32
32
 
33
- The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, overall centering, and topology-aware fake-complexity patterns. Compact mode evaluates preservation, row repair, linear-chain repair, sibling balancing, and combined compression strategies, then chooses the cleanest valid result. Sparse direct sibling fan-outs are packed around their parent at the minimum readable spacing, with each exclusive descendant branch moving as a unit and shared convergence focuses staying centered. Dense branches remain wider only when their contents need the room. It refuses invalid geometry such as overlapping or cramped focuses, bad parent order, rendered crossings, connectors through unrelated focuses, or an off-center canvas. Long connectors, fake detours, staircase or zigzag chains, and avoidable sibling imbalance are optimization priorities instead of independent rejection budgets.
33
+ The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, overall centering, and topology-aware fake-complexity patterns. Compact mode evaluates preservation, occupied-row repair, prerequisite-depth reflow, linear-chain repair, sibling balancing, and combined compression strategies, then chooses the cleanest valid result. Equivalent branches are aligned by dependency depth even when their authored rows differ, and connector shape is prioritized before raw canvas dimensions so a shorter canvas cannot win by introducing unnecessary horizontal detours. Sparse direct sibling fan-outs are packed around their parent at the minimum readable spacing, with each exclusive descendant branch moving as a unit and shared convergence focuses staying centered. Dense branches remain wider only when their contents need the room. It refuses invalid geometry such as overlapping or cramped focuses, bad parent order, rendered crossings, connectors through unrelated focuses, or an off-center canvas. Long connectors, fake detours, staircase or zigzag chains, and avoidable sibling imbalance are optimization priorities instead of independent rejection budgets.
34
34
 
35
35
  Use the default `layoutMode: "authored"` with a complete plan when deliberately designing coordinates or changing gameplay. Set `layoutMode: "compact"` with a complete plan when creating a large automatically arranged tree. Inspect and render calls remain useful before or after either rewrite mode, but a separate plan is not required for compact cleanup.
36
36
 
package/docs/gui.md CHANGED
@@ -52,6 +52,8 @@ Selectors accept an element name, instance ID, or source ID. Visibility expectat
52
52
 
53
53
  Broad inspection indexes localisation actually referenced by GUI source and returns a connected workspace projection when the complete source graph is very large. The resource records full and returned node and edge counts, so an agent can identify the relevant window without loading unrelated vanilla UI into its prompt. Rendering remains targeted to the selected root, state, and resolution.
54
54
 
55
+ GUI rewrites keep complete validation evidence in a linked JSON resource and retain the highest-priority findings in the transaction result. Large surrounding interface inventories therefore do not turn a small scalar patch into a transaction structure-limit error.
56
+
55
57
  ## Offline preview limits
56
58
 
57
59
  The renderer does not run the game engine. Each render includes a fidelity report that separates fields it models from fields it approximates, ignores, cannot resolve, or does not support. Treat that report as part of the review.
@@ -51,7 +51,7 @@ Game-backed and otherwise large workspaces automatically use focused analysis mo
51
51
 
52
52
  `hoi4.tech_render` returns authoritative JSON with deterministic SVG and PNG resources; set `includeHtml` when a bundled static report is useful. Available views are `summary`, `folder`, `dependencies`, `technology`, `doctrine`, `exclusive`, `memberships`, `bonuses`, `grants`, `unlocks`, `metadata`, `assets`, and `unresolved`.
53
53
 
54
- Folder renders use the actual folder assignments, gridbox geometry, technology coordinates, and HOI4 item size found in source. Technologies with `force_use_small_tech_layout = yes` render as 72×72 small items; normal technologies render as large items. The authoritative JSON and SVG expose `layoutSize` for every technology. Dependency and other semantic views are explicitly labelled as generated analysis layouts. Missing sprites, textures, or localisation remain identifiable from JSON and vector output even when no icon raster is decoded.
54
+ Folder renders use the actual folder assignments, gridbox geometry, technology coordinates, and HOI4 item size found in source. Technologies with `force_use_small_tech_layout = yes` render as compact icon items; normal technologies render as large items with their icon and label. Sprite declarations and texture files resolve through the active load order across the mod, configured dependencies, and the detected or configured game installation, so vanilla icons used by a mod appear in SVG, PNG, and HTML artifacts. The authoritative JSON and SVG expose `layoutSize`, rendered-icon coverage, and unresolved sprite names without embedding image data in JSON. Dependency and other semantic views are explicitly labelled as generated analysis layouts.
55
55
 
56
56
  Example folder render:
57
57
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hoi4-agent-tools",
3
3
  "mcpName": "io.github.klimPaskov/hoi4-agent-tools",
4
- "version": "2.5.2",
4
+ "version": "2.5.3",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",
@@ -69,6 +69,7 @@
69
69
  "format": "prettier --write .",
70
70
  "format:check": "prettier --check .",
71
71
  "test": "vitest run",
72
+ "test:all": "vitest run --shard=1/8 && vitest run --shard=2/8 && vitest run --shard=3/8 && vitest run --shard=4/8 && vitest run --shard=5/8 && vitest run --shard=6/8 && vitest run --shard=7/8 && vitest run --shard=8/8",
72
73
  "test:unit": "vitest run tests/unit",
73
74
  "test:integration": "vitest run tests/integration",
74
75
  "test:local": "tsx scripts/run-local-tests.ts",
@@ -91,7 +92,7 @@
91
92
  "release:order:verify": "tsx scripts/verify-npm-release-order.ts",
92
93
  "pack:check": "tsx scripts/check-package.ts",
93
94
  "inspector": "tsx scripts/run-inspector.ts",
94
- "check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run fixtures:check && npm run schemas:check && npm run build && npm run pack:check && npm run registry:validate",
95
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:all && npm run fixtures:check && npm run schemas:check && npm run build && npm run pack:check && npm run registry:validate",
95
96
  "prepack": "npm run build"
96
97
  },
97
98
  "dependencies": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/configuration.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/configuration.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/continuous-focus-palette.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/continuous-focus-palette.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/custom-weighted-pool.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/custom-weighted-pool.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/event-feature-manifest.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/event-feature-manifest.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/focus-plan.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/focus-plan.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/focus-planning-sidecar.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/focus-planning-sidecar.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/gui-animation-source.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/gui-animation-source.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/gui-helper.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/gui-helper.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/gui-scenario.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/gui-scenario.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/map-operation.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/map-operation.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "oneOf": [
5
5
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/operation-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/operation-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-analysis-result.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-analysis-result.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-compare-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-compare-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-evaluate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-evaluate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-inspect-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-inspect-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-render-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-render-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-scenario-set.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-scenario-set.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-sequence-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-sequence-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-simulate-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-simulate-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.2/schemas/probability-sweep-input.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.5.3/schemas/probability-sweep-input.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.klimPaskov/hoi4-agent-tools",
4
4
  "title": "HOI4 Agent Tools",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
- "version": "2.5.2",
6
+ "version": "2.5.3",
7
7
  "repository": {
8
8
  "url": "https://github.com/klimPaskov/hoi4-agent-tools",
9
9
  "source": "github"
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "hoi4-agent-tools",
16
- "version": "2.5.2",
16
+ "version": "2.5.3",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }