hoi4-agent-tools 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -1
  3. package/dist/bin/stdio.js +4 -0
  4. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  5. package/dist/hoi4_agent_tools/gui/assets.js +26 -14
  6. package/dist/hoi4_agent_tools/gui/renderer.js +4 -11
  7. package/dist/hoi4_agent_tools/gui/scenario.js +7 -1
  8. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +483 -0
  9. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -1
  10. package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -5
  11. package/dist/hoi4_agent_tools/probability/service.js +48 -6
  12. package/dist/hoi4_agent_tools/technology/compare.js +6 -1
  13. package/dist/hoi4_agent_tools/technology/graph.js +67 -2
  14. package/dist/hoi4_agent_tools/technology/model.js +1 -1
  15. package/dist/hoi4_agent_tools/technology/queries.js +4 -0
  16. package/dist/hoi4_agent_tools/technology/render.js +73 -28
  17. package/dist/hoi4_agent_tools/technology/service.js +4 -1
  18. package/dist/hoi4_agent_tools/technology/source-analysis.js +102 -19
  19. package/dist/hoi4_agent_tools/version.js +1 -1
  20. package/docs/gui.md +2 -0
  21. package/docs/images/readme/event-chain-mcp.png +0 -0
  22. package/docs/images/readme/probability-mcp.png +0 -0
  23. package/docs/images/readme/technology-tree-mcp.png +0 -0
  24. package/docs/probability.md +3 -1
  25. package/docs/technology.md +1 -1
  26. package/package.json +1 -1
  27. package/schemas/configuration.schema.json +1 -1
  28. package/schemas/continuous-focus-palette.schema.json +1 -1
  29. package/schemas/custom-weighted-pool.schema.json +1 -1
  30. package/schemas/event-feature-manifest.schema.json +1 -1
  31. package/schemas/focus-plan.schema.json +1 -1
  32. package/schemas/focus-planning-sidecar.schema.json +1 -1
  33. package/schemas/gui-animation-source.schema.json +1 -1
  34. package/schemas/gui-helper.schema.json +1 -1
  35. package/schemas/gui-scenario.schema.json +59 -55
  36. package/schemas/map-operation.schema.json +1 -1
  37. package/schemas/operation-result.schema.json +1 -1
  38. package/schemas/probability-analysis-result.schema.json +1 -1
  39. package/schemas/probability-compare-input.schema.json +1 -1
  40. package/schemas/probability-evaluate-input.schema.json +1 -1
  41. package/schemas/probability-inspect-input.schema.json +1 -1
  42. package/schemas/probability-render-input.schema.json +1 -1
  43. package/schemas/probability-scenario-set.schema.json +1 -1
  44. package/schemas/probability-sequence-input.schema.json +1 -1
  45. package/schemas/probability-simulate-input.schema.json +1 -1
  46. package/schemas/probability-sweep-input.schema.json +1 -1
  47. package/server.json +2 -2
@@ -155,7 +155,51 @@ function activeGridboxes(values) {
155
155
  .sort((left, right) => compareCodeUnits(left.folderId ?? '', right.folderId ?? '') ||
156
156
  compareCodeUnits(left.name, right.name));
157
157
  }
158
- function enrichPlacements(placements, edges, gridboxes, signal) {
158
+ function activeItemLayouts(values) {
159
+ const groups = new Map();
160
+ for (const layout of values) {
161
+ const key = `${layout.folderId}\0${layout.layoutSize}`;
162
+ const group = groups.get(key) ?? [];
163
+ group.push(layout);
164
+ groups.set(key, group);
165
+ }
166
+ return [...groups.values()]
167
+ .map((group) => group.sort((left, right) => right.loadOrder - left.loadOrder ||
168
+ compareCodeUnits(left.sourcePath, right.sourcePath) ||
169
+ left.location.start.offset - right.location.start.offset)[0])
170
+ .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
171
+ compareCodeUnits(left.layoutSize, right.layoutSize));
172
+ }
173
+ function activeYearMarkers(values) {
174
+ const groups = new Map();
175
+ for (const marker of values) {
176
+ const key = `${marker.folderId}\0${marker.name}`;
177
+ const group = groups.get(key) ?? [];
178
+ group.push(marker);
179
+ groups.set(key, group);
180
+ }
181
+ return [...groups.values()]
182
+ .map((group) => group.sort((left, right) => right.loadOrder - left.loadOrder ||
183
+ compareCodeUnits(left.sourcePath, right.sourcePath) ||
184
+ left.location.start.offset - right.location.start.offset)[0])
185
+ .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
186
+ left.year - right.year ||
187
+ compareCodeUnits(left.name, right.name));
188
+ }
189
+ function enrichPlacements(placements, edges, gridboxes, technologies, unlocks, itemLayouts, signal) {
190
+ const technologyById = new Map(technologies.map((technology) => [technology.id, technology]));
191
+ const unlockKindsByTechnology = new Map();
192
+ for (const unlock of unlocks) {
193
+ const kinds = unlockKindsByTechnology.get(unlock.technologyId) ?? new Set();
194
+ kinds.add(unlock.kind);
195
+ unlockKindsByTechnology.set(unlock.technologyId, kinds);
196
+ }
197
+ const layoutsByFolder = new Map();
198
+ for (const layout of itemLayouts) {
199
+ const layouts = layoutsByFolder.get(layout.folderId) ?? new Map();
200
+ layouts.set(layout.layoutSize, layout);
201
+ layoutsByFolder.set(layout.folderId, layouts);
202
+ }
159
203
  const placementsByTechnology = new Map();
160
204
  for (const placement of placements) {
161
205
  const group = placementsByTechnology.get(placement.technologyId) ?? [];
@@ -198,6 +242,15 @@ function enrichPlacements(placements, edges, gridboxes, signal) {
198
242
  : gridboxes.filter(({ name, folderId }) => name === expectedGridbox &&
199
243
  (folderId === undefined || folderId === placement.folderId));
200
244
  const gridbox = candidates[0];
245
+ const technology = technologyById.get(placement.technologyId);
246
+ const folderLayouts = layoutsByFolder.get(placement.folderId);
247
+ const unlockKinds = unlockKindsByTechnology.get(placement.technologyId) ?? new Set();
248
+ const unlocksEquipment = unlockKinds.has('equipment') || unlockKinds.has('equipment_module');
249
+ const layoutSize = technology?.layoutSize === 'small' ||
250
+ (folderLayouts?.has('small') === true && !unlocksEquipment)
251
+ ? 'small'
252
+ : 'large';
253
+ const itemLayout = folderLayouts?.get(layoutSize) ?? folderLayouts?.get('large');
201
254
  let pixelX;
202
255
  let pixelY;
203
256
  if (gridbox !== undefined &&
@@ -223,6 +276,12 @@ function enrichPlacements(placements, edges, gridboxes, signal) {
223
276
  ...(gridbox === undefined ? {} : { gridboxId: gridbox.id }),
224
277
  ...(pixelX === undefined ? {} : { pixelX }),
225
278
  ...(pixelY === undefined ? {} : { pixelY }),
279
+ layoutSize,
280
+ ...(itemLayout === undefined ? {} : { itemLayoutId: itemLayout.id }),
281
+ ...(itemLayout?.size.width === undefined ? {} : { layoutWidth: itemLayout.size.width }),
282
+ ...(itemLayout?.size.height === undefined ? {} : { layoutHeight: itemLayout.size.height }),
283
+ ...(itemLayout?.position.x === undefined ? {} : { layoutOffsetX: itemLayout.position.x }),
284
+ ...(itemLayout?.position.y === undefined ? {} : { layoutOffsetY: itemLayout.position.y }),
226
285
  geometryStatus: pixelX !== undefined && pixelY !== undefined
227
286
  ? 'source_pixel'
228
287
  : placement.x !== undefined && placement.y !== undefined
@@ -1093,6 +1152,8 @@ export function buildTechnologyGraph(snapshot, options) {
1093
1152
  const selectedFolders = activeDefinitions(fragments.flatMap(({ folders }) => folders));
1094
1153
  const folders = localiseFolders(selectedFolders.active, snapshot.index);
1095
1154
  const gridboxes = activeGridboxes(fragments.flatMap(({ gridboxes }) => gridboxes));
1155
+ const itemLayouts = activeItemLayouts(fragments.flatMap(({ itemLayouts }) => itemLayouts));
1156
+ const yearMarkers = activeYearMarkers(fragments.flatMap(({ yearMarkers }) => yearMarkers));
1096
1157
  const categories = activeCategories(fragments.flatMap(({ categories }) => categories));
1097
1158
  const selectedDoctrines = activeDefinitions(fragments.flatMap(({ doctrineDefinitions }) => doctrineDefinitions));
1098
1159
  const doctrineDefinitions = localiseDoctrines(selectedDoctrines.active, snapshot.index, assets);
@@ -1102,8 +1163,8 @@ export function buildTechnologyGraph(snapshot, options) {
1102
1163
  const unlockTargets = selectedTargets.active;
1103
1164
  const basePlacements = selectOwnedRecords(fragments.flatMap(({ placements }) => placements), ({ technologyId }) => technologyId, ({ location }) => location, technologyById).sort((left, right) => compareCodeUnits(left.id, right.id));
1104
1165
  const edges = selectOwnedRecords(fragments.flatMap(({ edges }) => edges), ({ from }) => from, ({ location }) => location, technologyById).sort((left, right) => compareCodeUnits(left.id, right.id));
1105
- const placements = enrichPlacements(basePlacements, edges, gridboxes, options.signal);
1106
1166
  const unlocks = resolveUnlocks(selectOwnedRecords(fragments.flatMap(({ unlocks }) => unlocks), ({ technologyId }) => technologyId, ({ location }) => location, technologyById).sort((left, right) => compareCodeUnits(left.id, right.id)), unlockTargets, snapshot.complete);
1167
+ const placements = enrichPlacements(basePlacements, edges, gridboxes, technologies, unlocks, itemLayouts, options.signal);
1107
1168
  const unresolved = fragments.flatMap(({ unresolved }) => unresolved);
1108
1169
  const helperCalls = fragments.flatMap(({ helperCalls }) => helperCalls);
1109
1170
  const externalReferences = expandHelperReferences(fragments.flatMap(({ externalReferences }) => externalReferences), helperCalls, unresolved, options.analysisMode ?? 'full', options.signal);
@@ -1167,6 +1228,8 @@ export function buildTechnologyGraph(snapshot, options) {
1167
1228
  folders,
1168
1229
  placements,
1169
1230
  gridboxes,
1231
+ itemLayouts,
1232
+ yearMarkers,
1170
1233
  edges,
1171
1234
  categories,
1172
1235
  doctrineDefinitions,
@@ -1183,6 +1246,8 @@ export function buildTechnologyGraph(snapshot, options) {
1183
1246
  folderCount: folders.length,
1184
1247
  placementCount: placements.length,
1185
1248
  gridboxCount: gridboxes.length,
1249
+ itemLayoutCount: itemLayouts.length,
1250
+ yearMarkerCount: yearMarkers.length,
1186
1251
  prerequisiteCount: edges.filter(({ kind }) => kind === 'prerequisite').length,
1187
1252
  exclusiveCount: edges.filter(({ kind }) => kind === 'exclusive').length,
1188
1253
  categoryCount: categories.length,
@@ -1,2 +1,2 @@
1
- export const TECHNOLOGY_GRAPH_SCHEMA_VERSION = 1;
1
+ export const TECHNOLOGY_GRAPH_SCHEMA_VERSION = 2;
2
2
  export const TECHNOLOGY_PARSER_VERSION = 'clausewitz-cst.v1';
@@ -15,6 +15,8 @@ function technologyRecordCount(graph) {
15
15
  graph.categories.length +
16
16
  graph.placements.length +
17
17
  graph.gridboxes.length +
18
+ graph.itemLayouts.length +
19
+ graph.yearMarkers.length +
18
20
  graph.edges.length +
19
21
  graph.unlocks.length +
20
22
  graph.unlockTargets.length +
@@ -62,6 +64,8 @@ export function technologyScanReport(graph, fullGraphRecordLimit = TECHNOLOGY_SC
62
64
  categories: graph.categories.length,
63
65
  placements: graph.placements.length,
64
66
  gridboxes: graph.gridboxes.length,
67
+ itemLayouts: graph.itemLayouts.length,
68
+ yearMarkers: graph.yearMarkers.length,
65
69
  edges: graph.edges.length,
66
70
  unlocks: graph.unlocks.length,
67
71
  unlockTargets: graph.unlockTargets.length,
@@ -46,7 +46,7 @@ function technologyNode(graph, technologyId, placement) {
46
46
  kind: technology.kind,
47
47
  sourcePath: technology.source.path,
48
48
  sourceLine: technology.source.location.start.line,
49
- layoutSize: technology.layoutSize,
49
+ layoutSize: placement?.layoutSize ?? technology.layoutSize,
50
50
  iconSprite: technology.icon.sprite,
51
51
  ...(placement === undefined ? {} : { placement }),
52
52
  };
@@ -61,6 +61,7 @@ function sortedUniqueNodes(values) {
61
61
  function viewSelection(graph, options) {
62
62
  const nodes = [];
63
63
  const edges = [];
64
+ let yearMarkers = [];
64
65
  let title;
65
66
  let sourceAccurate = false;
66
67
  if (options.view === 'summary') {
@@ -117,6 +118,7 @@ function viewSelection(graph, options) {
117
118
  .filter(({ from, to }) => selected.has(from) && selected.has(to))
118
119
  .map(({ id, from, to, kind }) => ({ id, from, to, kind })));
119
120
  sourceAccurate = true;
121
+ yearMarkers = graph.yearMarkers.filter(({ folderId }) => folderId === options.folderId);
120
122
  }
121
123
  else if (options.view === 'dependencies' || options.view === 'technology') {
122
124
  const ids = options.technologyId === undefined
@@ -407,6 +409,7 @@ function viewSelection(graph, options) {
407
409
  return {
408
410
  nodes: selectedNodes,
409
411
  edges: selectedEdges,
412
+ yearMarkers,
410
413
  sourceAccurate,
411
414
  title,
412
415
  payload: {
@@ -416,6 +419,7 @@ function viewSelection(graph, options) {
416
419
  generatedAnalysisLayout: !sourceAccurate,
417
420
  nodes: selectedNodes,
418
421
  edges: selectedEdges,
422
+ yearMarkers,
419
423
  graphRevision: graph.revision,
420
424
  analysisBoundary: graph.analysisBoundary,
421
425
  },
@@ -424,38 +428,61 @@ function viewSelection(graph, options) {
424
428
  function issueKey(issue) {
425
429
  return sha256Bytes(canonicalJson({ code: issue.code, details: issue.details })).slice(0, 20);
426
430
  }
427
- function layoutFolder(nodes) {
428
- const dimensions = (node) => node.layoutSize === 'small'
429
- ? { width: SMALL_NODE_WIDTH, height: SMALL_NODE_HEIGHT }
430
- : { width: LARGE_NODE_WIDTH, height: LARGE_NODE_HEIGHT };
431
+ function layoutFolder(nodes, yearMarkers) {
432
+ const dimensions = (node) => node.placement?.layoutWidth !== undefined && node.placement.layoutHeight !== undefined
433
+ ? { width: node.placement.layoutWidth, height: node.placement.layoutHeight }
434
+ : node.layoutSize === 'small'
435
+ ? { width: SMALL_NODE_WIDTH, height: SMALL_NODE_HEIGHT }
436
+ : { width: LARGE_NODE_WIDTH, height: LARGE_NODE_HEIGHT };
431
437
  const sourcePixel = nodes.filter(({ placement }) => placement?.pixelX !== undefined && placement.pixelY !== undefined);
432
438
  if (sourcePixel.length === nodes.length && sourcePixel.length > 0) {
433
- const minimumX = Math.min(...sourcePixel.map(({ placement }) => placement.pixelX));
434
- const minimumY = Math.min(...sourcePixel.map(({ placement }) => placement.pixelY));
435
- return sourcePixel.map((node) => {
439
+ const sourceNodeX = (node) => node.placement.pixelX + (node.placement?.layoutOffsetX ?? 0);
440
+ 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));
443
+ const positionedNodes = sourcePixel.map((node) => {
436
444
  const size = dimensions(node);
437
445
  return {
438
446
  ...node,
439
- x: PADDING + node.placement.pixelX - minimumX,
440
- y: HEADER_HEIGHT + PADDING + node.placement.pixelY - minimumY,
447
+ x: PADDING + sourceNodeX(node) - minimumX,
448
+ y: HEADER_HEIGHT + PADDING + sourceNodeY(node) - minimumY,
441
449
  ...size,
442
450
  };
443
451
  });
452
+ const positionedYearMarkers = yearMarkers.map((marker) => ({
453
+ ...marker,
454
+ x: PADDING + marker.position.x - minimumX,
455
+ y: HEADER_HEIGHT + PADDING + marker.position.y - minimumY,
456
+ }));
457
+ const xValues = yearMarkers.map(({ position }) => position.x);
458
+ const yValues = yearMarkers.map(({ position }) => position.y);
459
+ const xSpan = xValues.length === 0 ? 0 : Math.max(...xValues) - Math.min(...xValues);
460
+ const ySpan = yValues.length === 0 ? 0 : Math.max(...yValues) - Math.min(...yValues);
461
+ return {
462
+ nodes: positionedNodes,
463
+ yearMarkers: positionedYearMarkers,
464
+ ...(yearMarkers.length === 0
465
+ ? {}
466
+ : { yearAxis: xSpan >= ySpan ? 'horizontal' : 'vertical' }),
467
+ };
444
468
  }
445
469
  const branchRoots = [
446
470
  ...new Set(nodes.map(({ placement }) => placement?.branchRootId ?? '<unresolved>')),
447
471
  ].sort(compareCodeUnits);
448
472
  const lanes = new Map(branchRoots.map((id, index) => [id, index]));
449
- return nodes.map((node, index) => {
450
- const placement = node.placement;
451
- const lane = lanes.get(placement?.branchRootId ?? '<unresolved>') ?? 0;
452
- const size = dimensions(node);
453
- const x = PADDING + lane * 680 + (placement?.x ?? index % 3) * (LARGE_NODE_WIDTH + GAP_X);
454
- const y = HEADER_HEIGHT +
455
- PADDING +
456
- (placement?.y ?? Math.floor(index / 3)) * (LARGE_NODE_HEIGHT + GAP_Y);
457
- return { ...node, x, y, ...size };
458
- });
473
+ return {
474
+ nodes: nodes.map((node, index) => {
475
+ const placement = node.placement;
476
+ const lane = lanes.get(placement?.branchRootId ?? '<unresolved>') ?? 0;
477
+ const size = dimensions(node);
478
+ const x = PADDING + lane * 680 + (placement?.x ?? index % 3) * (LARGE_NODE_WIDTH + GAP_X);
479
+ const y = HEADER_HEIGHT +
480
+ PADDING +
481
+ (placement?.y ?? Math.floor(index / 3)) * (LARGE_NODE_HEIGHT + GAP_Y);
482
+ return { ...node, x, y, ...size };
483
+ }),
484
+ yearMarkers: [],
485
+ };
459
486
  }
460
487
  function layoutGenerated(nodes, edges) {
461
488
  const ids = new Set(nodes.map(({ id }) => id));
@@ -544,9 +571,9 @@ function colours(kind) {
544
571
  removed: { fill: '#4a2828', stroke: '#ef7d7d' },
545
572
  }[kind];
546
573
  }
547
- function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris) {
548
- const maximumX = Math.max(PADDING + LARGE_NODE_WIDTH, ...positioned.map(({ x, width }) => x + width));
549
- const maximumY = Math.max(HEADER_HEIGHT + LARGE_NODE_HEIGHT, ...positioned.map(({ y, height }) => y + height));
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));
550
577
  const width = Math.ceil(maximumX + PADDING);
551
578
  const height = Math.ceil(maximumY + PADDING);
552
579
  if (width > 16_384 || height > 16_384 || width * height > RENDER_MAX_PIXELS)
@@ -556,6 +583,19 @@ function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris) {
556
583
  });
557
584
  const byId = new Map(positioned.map((node) => [node.id, node]));
558
585
  const text = new DeterministicSvgTextRenderer();
586
+ 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"/>`;
590
+ const label = text.render(String(marker.year), {
591
+ x: marker.x,
592
+ y: marker.y + 22,
593
+ fontSize: 18,
594
+ fill: '#c7d4df',
595
+ weight: 700,
596
+ });
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>`;
598
+ });
559
599
  const edgeSvg = edges.flatMap((edge) => {
560
600
  const from = byId.get(edge.from);
561
601
  const to = byId.get(edge.to);
@@ -624,7 +664,7 @@ function svgFor(titleText, positioned, edges, sourceAccurate, iconDataUris) {
624
664
  return {
625
665
  width,
626
666
  height,
627
- 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>${edgeSvg.join('')}</g><g>${nodeSvg.join('')}</g></svg>`,
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>`,
628
668
  };
629
669
  }
630
670
  function htmlFor(title, svg, json) {
@@ -637,16 +677,17 @@ export async function renderTechnologyGraph(graph, options) {
637
677
  const retained = selection.nodes.slice(0, maximum);
638
678
  const retainedIds = new Set(retained.map(({ id }) => id));
639
679
  const edges = selection.edges.filter(({ from, to }) => retainedIds.has(from) && retainedIds.has(to));
640
- const positioned = selection.sourceAccurate
641
- ? layoutFolder(retained)
642
- : layoutGenerated(retained, edges);
680
+ const folderLayout = selection.sourceAccurate
681
+ ? layoutFolder(retained, selection.yearMarkers)
682
+ : { nodes: layoutGenerated(retained, edges), yearMarkers: [] };
683
+ const positioned = folderLayout.nodes;
643
684
  const iconDataUris = options.iconDataUris ?? {};
644
685
  const requestedIconSprites = [
645
686
  ...new Set(retained.flatMap(({ iconSprite }) => (iconSprite === undefined ? [] : [iconSprite]))),
646
687
  ].sort(compareCodeUnits);
647
688
  const renderedIconSprites = requestedIconSprites.filter((sprite) => iconDataUris[sprite] !== undefined);
648
689
  const unresolvedIconSprites = requestedIconSprites.filter((sprite) => iconDataUris[sprite] === undefined);
649
- const rendered = svgFor(selection.title, positioned, edges, selection.sourceAccurate, iconDataUris);
690
+ const rendered = svgFor(selection.title, positioned, edges, selection.sourceAccurate, iconDataUris, folderLayout.yearMarkers, folderLayout.yearAxis);
650
691
  const budget = options.budget ?? new RenderBudget();
651
692
  budget.reserve(rendered.width, rendered.height, `technology ${options.view} render`);
652
693
  budget.reserveRasterOperation(`technology-render:${graph.revision}:${options.view}:${sha256Bytes(rendered.svg)}`, `technology ${options.view} rasterization`);
@@ -667,6 +708,10 @@ export async function renderTechnologyGraph(graph, options) {
667
708
  unresolvedSprites: unresolvedIconSprites,
668
709
  },
669
710
  render: { width: rendered.width, height: rendered.height },
711
+ yearGuide: {
712
+ axis: folderLayout.yearAxis ?? null,
713
+ markerCount: folderLayout.yearMarkers.length,
714
+ },
670
715
  })}\n`;
671
716
  const html = options.includeHtml === true ? htmlFor(selection.title, rendered.svg, json) : undefined;
672
717
  return {
@@ -10,6 +10,7 @@ import { GuiAssetCatalog } from '../gui/assets.js';
10
10
  import { buildGuiSourceGraph } from '../gui/source-graph.js';
11
11
  import { compareTechnologyGraphs } from './compare.js';
12
12
  import { buildTechnologyGraph, technologyAssetPatterns } from './graph.js';
13
+ import { TECHNOLOGY_GRAPH_SCHEMA_VERSION } from './model.js';
13
14
  import { analyzeTechnologyImpact, discoverTechnologyFolders, explainTechnology, inspectTechnologyUnlocks, lintTechnologyGraph, technologyBonusCoverage, technologyScanReport, traceTechnology, } from './queries.js';
14
15
  import { renderTechnologyGraph, technologyRenderIconSprites, } from './render.js';
15
16
  const FRAGMENT_CACHE_ENTRIES = 8_192;
@@ -293,13 +294,15 @@ function validateGraph(value) {
293
294
  throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Technology graph artifact is not an object');
294
295
  const graph = value;
295
296
  const sourceHashes = value.sourceHashes;
296
- if (graph.schemaVersion !== 1 ||
297
+ if (graph.schemaVersion !== TECHNOLOGY_GRAPH_SCHEMA_VERSION ||
297
298
  typeof graph.workspaceId !== 'string' ||
298
299
  typeof graph.workspaceIdentity !== 'string' ||
299
300
  typeof graph.revision !== 'string' ||
300
301
  !Array.isArray(graph.technologies) ||
301
302
  !Array.isArray(graph.edges) ||
302
303
  !Array.isArray(graph.placements) ||
304
+ !Array.isArray(graph.itemLayouts) ||
305
+ !Array.isArray(graph.yearMarkers) ||
303
306
  sourceHashes === null ||
304
307
  typeof sourceHashes !== 'object')
305
308
  throw new ServiceError('TECH_GRAPH_ARTIFACT_INVALID', 'Technology graph artifact has an invalid schema');
@@ -350,11 +350,7 @@ function parseTechnologyDefinitions(file, document, fragment) {
350
350
  ].sort(compareCodeUnits);
351
351
  const layoutExpression = firstValue(block, 'force_use_small_tech_layout');
352
352
  const smallLayout = booleanValue(layoutExpression);
353
- const layoutSize = layoutExpression === undefined || smallLayout === false
354
- ? 'large'
355
- : smallLayout === true
356
- ? 'small'
357
- : 'unknown';
353
+ const layoutSize = smallLayout === true ? 'small' : 'unknown';
358
354
  const unsupportedFields = fragment.placements
359
355
  .filter(({ technologyId, xExpression, yExpression }) => technologyId === id && (xExpression !== undefined || yExpression !== undefined))
360
356
  .map((placement) => ({
@@ -581,12 +577,22 @@ function parseModernDoctrines(file, document, fragment) {
581
577
  }
582
578
  }
583
579
  }
580
+ function technologyFolderContainer(namedContainers) {
581
+ return [...namedContainers].reverse().find((name) => /_folder(?:_|$)/iu.test(name));
582
+ }
583
+ function technologyItemLayoutName(name) {
584
+ const small = /^techtree_(.+)_small_item$/iu.exec(name);
585
+ if (small?.[1] !== undefined)
586
+ return { folderId: small[1], layoutSize: 'small' };
587
+ const large = /^techtree_(.+)_item$/iu.exec(name);
588
+ if (large?.[1] !== undefined)
589
+ return { folderId: large[1], layoutSize: 'large' };
590
+ return undefined;
591
+ }
584
592
  function parseTechnologyGridboxes(file, document, fragment, signal) {
585
593
  if (!normalizedPath(file).endsWith('.gui'))
586
594
  return;
587
- const stack = [
588
- { block: document.root, namedContainers: [] },
589
- ];
595
+ const stack = [{ block: document.root, namedContainers: [], offsetX: 0, offsetY: 0 }];
590
596
  let visited = 0;
591
597
  while (stack.length > 0) {
592
598
  if (visited % 128 === 0)
@@ -599,11 +605,14 @@ function parseTechnologyGridboxes(file, document, fragment, signal) {
599
605
  const block = assignment.value;
600
606
  const elementType = assignment.key.value.toLowerCase();
601
607
  const name = firstScalar(block, 'name');
608
+ const position = childBlocks(block, 'position')[0];
609
+ const localX = numericLiteral(position === undefined ? undefined : firstValue(position, 'x'));
610
+ const localY = numericLiteral(position === undefined ? undefined : firstValue(position, 'y'));
611
+ const absoluteX = current.offsetX + (localX ?? 0);
612
+ const absoluteY = current.offsetY + (localY ?? 0);
613
+ const folderId = technologyFolderContainer(current.namedContainers);
602
614
  if (elementType === 'gridboxtype' && name?.value.endsWith('_tree') === true) {
603
- const position = childBlocks(block, 'position')[0];
604
615
  const slotSize = childBlocks(block, 'slotsize')[0];
605
- const x = numericLiteral(position === undefined ? undefined : firstValue(position, 'x'));
606
- const y = numericLiteral(position === undefined ? undefined : firstValue(position, 'y'));
607
616
  const width = numericLiteral(slotSize === undefined ? undefined : firstValue(slotSize, 'width'));
608
617
  const height = numericLiteral(slotSize === undefined ? undefined : firstValue(slotSize, 'height'));
609
618
  const gridbox = {
@@ -613,16 +622,14 @@ function parseTechnologyGridboxes(file, document, fragment, signal) {
613
622
  offset: assignment.start,
614
623
  }),
615
624
  name: name.value,
616
- ...(current.namedContainers.at(-1) === undefined
617
- ? {}
618
- : { folderId: current.namedContainers.at(-1) }),
625
+ ...(folderId === undefined ? {} : { folderId }),
619
626
  position: {
620
- ...(x === undefined ? {} : { x }),
621
- ...(y === undefined ? {} : { y }),
622
- ...(position === undefined || x !== undefined
627
+ ...(localX === undefined ? {} : { x: absoluteX }),
628
+ ...(localY === undefined ? {} : { y: absoluteY }),
629
+ ...(position === undefined || localX !== undefined
623
630
  ? {}
624
631
  : { xExpression: firstValue(position, 'x') ?? '<missing>' }),
625
- ...(position === undefined || y !== undefined
632
+ ...(position === undefined || localY !== undefined
626
633
  ? {}
627
634
  : { yExpression: firstValue(position, 'y') ?? '<missing>' }),
628
635
  },
@@ -645,11 +652,85 @@ function parseTechnologyGridboxes(file, document, fragment, signal) {
645
652
  };
646
653
  fragment.gridboxes.push(gridbox);
647
654
  }
655
+ if (elementType === 'containerwindowtype' && name !== undefined) {
656
+ const item = technologyItemLayoutName(name.value);
657
+ if (item !== undefined) {
658
+ const size = childBlocks(block, 'size')[0];
659
+ const width = numericLiteral(size === undefined ? undefined : firstValue(size, 'width'));
660
+ const height = numericLiteral(size === undefined ? undefined : firstValue(size, 'height'));
661
+ const layout = {
662
+ id: deterministicId('tech-item-layout', {
663
+ name: name.value,
664
+ path: file.displayPath,
665
+ offset: assignment.start,
666
+ }),
667
+ name: name.value,
668
+ folderId: item.folderId,
669
+ layoutSize: item.layoutSize,
670
+ position: {
671
+ ...(localX === undefined ? {} : { x: localX }),
672
+ ...(localY === undefined ? {} : { y: localY }),
673
+ ...(position === undefined || localX !== undefined
674
+ ? {}
675
+ : { xExpression: firstValue(position, 'x') ?? '<missing>' }),
676
+ ...(position === undefined || localY !== undefined
677
+ ? {}
678
+ : { yExpression: firstValue(position, 'y') ?? '<missing>' }),
679
+ },
680
+ size: {
681
+ ...(width === undefined ? {} : { width }),
682
+ ...(height === undefined ? {} : { height }),
683
+ ...(size === undefined || width !== undefined
684
+ ? {}
685
+ : { widthExpression: firstValue(size, 'width') ?? '<missing>' }),
686
+ ...(size === undefined || height !== undefined
687
+ ? {}
688
+ : { heightExpression: firstValue(size, 'height') ?? '<missing>' }),
689
+ },
690
+ location: nodeLocation(document, assignment, name.value),
691
+ sourcePath: file.displayPath,
692
+ loadOrder: file.loadOrder,
693
+ };
694
+ fragment.itemLayouts.push(layout);
695
+ }
696
+ }
697
+ if (elementType === 'instanttextboxtype' && name !== undefined && folderId !== undefined) {
698
+ const text = firstValue(block, 'text');
699
+ const year = text === undefined
700
+ ? undefined
701
+ : /^(?:18|19|20)\d{2}$/u.test(text)
702
+ ? Number(text)
703
+ : undefined;
704
+ if (year !== undefined && localX !== undefined && localY !== undefined) {
705
+ const marker = {
706
+ id: deterministicId('tech-year-marker', {
707
+ name: name.value,
708
+ year,
709
+ path: file.displayPath,
710
+ offset: assignment.start,
711
+ }),
712
+ name: name.value,
713
+ folderId,
714
+ year,
715
+ position: { x: absoluteX, y: absoluteY },
716
+ location: nodeLocation(document, assignment, name.value),
717
+ sourcePath: file.displayPath,
718
+ loadOrder: file.loadOrder,
719
+ };
720
+ fragment.yearMarkers.push(marker);
721
+ }
722
+ }
648
723
  const namedContainers = name !== undefined &&
649
724
  (elementType === 'containerwindowtype' || elementType === 'windowtype')
650
725
  ? [...current.namedContainers, name.value]
651
726
  : current.namedContainers;
652
- stack.push({ block, namedContainers });
727
+ const positionedContainer = elementType === 'containerwindowtype' || elementType === 'windowtype';
728
+ stack.push({
729
+ block,
730
+ namedContainers,
731
+ offsetX: positionedContainer ? absoluteX : current.offsetX,
732
+ offsetY: positionedContainer ? absoluteY : current.offsetY,
733
+ });
653
734
  }
654
735
  }
655
736
  }
@@ -913,6 +994,8 @@ export function analyzeTechnologySource(file, context) {
913
994
  folders: [],
914
995
  placements: [],
915
996
  gridboxes: [],
997
+ itemLayouts: [],
998
+ yearMarkers: [],
916
999
  edges: [],
917
1000
  categories: [],
918
1001
  doctrineDefinitions: [],
@@ -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 = '3.0.0';
3
+ export const PACKAGE_VERSION = '3.0.2';
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/gui.md CHANGED
@@ -35,6 +35,8 @@ The renderer resolves sprites and fonts through the complete mod, dependency, an
35
35
 
36
36
  Bitmap text uses the language-appropriate HOI4 font definition and its real glyph atlas. Header, serif, compact, typewriter, black, inverted, and other declared faces keep their own glyph shapes, native metrics, face colour, border colour, language override, and font-specific `textcolors` palette. The renderer reads BMFont channel roles, separates face and outline coverage, and recognizes both vanilla black-on-white masks and conventional white-on-transparent custom masks. HOI4 localisation colour runs such as `§Y` and `§R` therefore colour the glyph face while retaining its sharp antialiasing and declared border instead of flattening the glyph into a blurred monochrome mask or switching to the tool font. The default language uses the base font rather than an unrelated language override, and the renderer recognizes installed-game atlases whose filename omits a page suffix declared by the matching `.fnt` file.
37
37
 
38
+ `uiScale` is the in-game UI scale, not an image zoom. Use `1` for the normal 1920×1080 layout. Enlarged bitmap-font glyphs are pre-scaled from their real atlas with deterministic pixel sampling, and bitmap colours use alpha masks so local and VPS renders produce the same pixels.
39
+
38
40
  Layout follows Clausewitz orientation and element-origin anchors, including `CENTER_LEFT`, `CENTER_RIGHT`, `CENTER_UP`, `CENTER_DOWN`, `origo`, `centerposition`, inherited container coordinate origins, local scale, and both `%` and `%%` relative dimensions. Text boxes use native font metrics together with `maxWidth`, `maxHeight`, alignment, wrapping, and `fixedsize` clipping. Cornered tile sprites use fixed nine-slice borders and optional center tiling; progress bars composite their filled and background textures; masked shields composite their background and mask textures.
39
41
 
40
42
  Scripted-GUI composition follows `parent_scripted_gui` chains even when the linked child window is a top-level sibling in the `.gui` file. Literal `always = yes` and `always = no` visibility and click-enabled blocks are applied automatically. A scenario's `values` object supplies concrete runtime values in one place: variable names resolve numeric text, scripted-localisation names resolve dynamic labels, a progress-bar element name sets its fill value, and element keys ending in `.image`, `.frame`, `.x`, `.y`, `.visible`, or `.enabled` update that visual property. Scripted-GUI property expressions such as `image = "[GetMeterSprite]"` or `x = meter_fill_width` consume matching scenario values automatically. Dynamic text is parsed after scenario and scripted-localisation substitution, so a runtime string containing `£command_power` or `£[GetResourceIcon]` renders the resolved inline HOI4 text icon instead of literal marker text. Dynamic lists instantiate their declared `entry_container` or `country_scope_entry_container`; each scenario row can select the country template with `countryScope`, choose an explicit `entryContainer`, provide `countryTag` and `ideology` for `GetFlag`, and supply row-scoped text, image, frame, position, visibility, and enabled values. Masked country sprites use the actual mask and overlay dimensions, so custom flag sizes are discovered from GFX assets rather than assumed by the renderer.
@@ -16,7 +16,9 @@ Use them for event timing and option weights, decision and mission scores, focus
16
16
 
17
17
  All seven tools are read-only. Proposed source is parsed in memory and never written. When an installed game root is configured, evaluation fails closed unless `launcher-settings.json` identifies the supported HOI4 build and checksum. Results otherwise state that they target the adapter version without claiming local-game verification.
18
18
 
19
- Pass a source without an adapter when the surface type is not yet known. Inspection returns every compatible adapter, candidate counts, and example candidate IDs. If a requested adapter, identifier, or candidate pool does not match the source, inspection returns the same discovery result with an explanation and suggested adapter instead of `PROBABILITY_SURFACE_EMPTY`; evaluation, sweeps, and simulation remain strict and still reject an empty requested surface.
19
+ Pass a source without an adapter when the surface type is not yet known. Inspection returns every compatible adapter, candidate counts, and example candidate IDs. If a requested adapter, identifier, or candidate pool does not match the source, inspection returns the same discovery result with an explanation and suggested adapter instead of `PROBABILITY_SURFACE_EMPTY`.
20
+
21
+ Evaluation, sweep, simulation, and source comparison also accept an omitted adapter. When a source selector identifies one unambiguous weighted surface, the analyzer selects that source-backed adapter automatically. If a caller supplies a mismatched adapter or `custom_weighted_pool` for a source-backed request, the analyzer corrects it when the source has exactly one match. `customPoolManifest`, `beforeManifest`, and `afterManifest` use the custom-pool adapter automatically; `PROBABILITY_SURFACE_EMPTY` is reserved for sources with no weighted surface, while genuinely multi-adapter sources ask the caller to narrow by identifier or line.
20
22
 
21
23
  ## Scenarios
22
24
 
@@ -49,7 +49,7 @@ Game-backed and otherwise large workspaces automatically use focused analysis mo
49
49
 
50
50
  ## Render
51
51
 
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`.
52
+ `hoi4.tech_render` returns authoritative JSON with deterministic SVG and PNG resources; set `includeHtml` when a bundled static report is useful. Folder renders read the actual `techtree_<folder>_item` and `techtree_<folder>_small_item` geometry, choose the small item for technologies that do not unlock equipment or explicitly force it, and include the source GUI's horizontal or vertical year guide. Available views are `summary`, `folder`, `dependencies`, `technology`, `doctrine`, `exclusive`, `memberships`, `bonuses`, `grants`, `unlocks`, `metadata`, `assets`, and `unresolved`.
53
53
 
54
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
 
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": "3.0.0",
4
+ "version": "3.0.2",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.0/schemas/configuration.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.2/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/v3.0.0/schemas/continuous-focus-palette.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.2/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/v3.0.0/schemas/custom-weighted-pool.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.2/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/v3.0.0/schemas/event-feature-manifest.schema.json",
2
+ "$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v3.0.2/schemas/event-feature-manifest.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "type": "object",
5
5
  "properties": {