hoi4-agent-tools 3.0.0 → 3.0.1

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 +6 -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/mcp/tools/chaosx.js +483 -0
  6. package/dist/hoi4_agent_tools/mcp/tools/probability.js +9 -5
  7. package/dist/hoi4_agent_tools/probability/service.js +48 -6
  8. package/dist/hoi4_agent_tools/technology/compare.js +6 -1
  9. package/dist/hoi4_agent_tools/technology/graph.js +67 -2
  10. package/dist/hoi4_agent_tools/technology/model.js +1 -1
  11. package/dist/hoi4_agent_tools/technology/queries.js +4 -0
  12. package/dist/hoi4_agent_tools/technology/render.js +73 -28
  13. package/dist/hoi4_agent_tools/technology/service.js +4 -1
  14. package/dist/hoi4_agent_tools/technology/source-analysis.js +102 -19
  15. package/dist/hoi4_agent_tools/version.js +1 -1
  16. package/docs/images/readme/event-chain-mcp.png +0 -0
  17. package/docs/images/readme/probability-mcp.png +0 -0
  18. package/docs/images/readme/technology-tree-mcp.png +0 -0
  19. package/docs/probability.md +3 -1
  20. package/docs/technology.md +1 -1
  21. package/package.json +1 -1
  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
@@ -17,6 +17,43 @@ import { Rational } from './rational.js';
17
17
  import { discoverWeightedSurface, inventoryWeightedSurfaces, } from './source-analysis.js';
18
18
  import { sweepSurface } from './sweep.js';
19
19
  const states = new WeakMap();
20
+ function resolveSourceBackedAdapter(snapshot, source, candidatePool, requested) {
21
+ if (requested !== undefined && requested !== 'custom_weighted_pool') {
22
+ try {
23
+ discoverWeightedSurface(snapshot, requested, source, candidatePool);
24
+ return requested;
25
+ }
26
+ catch (error) {
27
+ if (!(error instanceof ServiceError) ||
28
+ (error.code !== 'PROBABILITY_SURFACE_EMPTY' &&
29
+ error.code !== 'PROBABILITY_IDENTIFIER_NOT_FOUND'))
30
+ throw error;
31
+ }
32
+ }
33
+ const inventory = inventoryWeightedSurfaces(snapshot, source, candidatePool);
34
+ const viable = inventory.adapters
35
+ .filter(({ candidateCount, identifierMatchCount, candidatePoolMatchCount }) => candidateCount > 0 &&
36
+ (source.identifier === undefined || identifierMatchCount > 0) &&
37
+ (candidatePool.length === 0 || candidatePoolMatchCount > 0))
38
+ .sort((left, right) => right.identifierMatchCount - left.identifierMatchCount ||
39
+ right.candidatePoolMatchCount - left.candidatePoolMatchCount ||
40
+ right.candidateCount - left.candidateCount ||
41
+ compareCodeUnits(left.adapterId, right.adapterId));
42
+ if (viable.length === 1)
43
+ return viable[0].adapterId;
44
+ if (viable.length === 0)
45
+ throw new ServiceError('PROBABILITY_SURFACE_EMPTY', 'No weighted blocks matched this request', {
46
+ requestedAdapter: requested,
47
+ source,
48
+ candidatePool,
49
+ availableAdapters: inventory.adapters.filter(({ candidateCount }) => candidateCount > 0),
50
+ });
51
+ throw new ServiceError('PROBABILITY_ADAPTER_AMBIGUOUS', 'Several weighted adapters match this source; select one adapter or narrow the source by identifier or line', {
52
+ requestedAdapter: requested,
53
+ source,
54
+ matchingAdapters: viable,
55
+ });
56
+ }
20
57
  function analyzerState(engine) {
21
58
  let state = states.get(engine);
22
59
  if (state === undefined) {
@@ -853,7 +890,9 @@ export class ProbabilityAnalyzer {
853
890
  return { adapters: probabilityAdapters(), artifacts: [], filesScanned: [] };
854
891
  if (customPoolManifest !== undefined && source !== undefined)
855
892
  throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide either source or customPoolManifest for inspection');
856
- if (customPoolManifest !== undefined && adapter !== 'custom_weighted_pool')
893
+ if (customPoolManifest !== undefined &&
894
+ adapter !== undefined &&
895
+ adapter !== 'custom_weighted_pool')
857
896
  throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest inspection requires adapter custom_weighted_pool');
858
897
  const snapshot = await this.scan(context, [source?.path], source === undefined ? 'none' : source.inlineClausewitz === undefined ? 'domain' : 'shared');
859
898
  if (customPoolManifest !== undefined) {
@@ -1040,15 +1079,18 @@ export class ProbabilityAnalyzer {
1040
1079
  const usingManifest = request.customPoolManifest !== undefined;
1041
1080
  if (usingManifest !== (request.source === undefined))
1042
1081
  throw new ServiceError('PROBABILITY_INPUT_AMBIGUOUS', 'Provide exactly one source or customPoolManifest');
1043
- if (usingManifest && request.adapter !== 'custom_weighted_pool')
1082
+ if (usingManifest &&
1083
+ request.adapter !== undefined &&
1084
+ request.adapter !== 'custom_weighted_pool')
1044
1085
  throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest requires the custom_weighted_pool adapter');
1045
- if (!usingManifest && request.adapter === 'custom_weighted_pool')
1046
- throw new ServiceError('PROBABILITY_MANIFEST_REQUIRED', 'custom_weighted_pool requires customPoolManifest');
1047
- const gameVersionVerification = await this.verifyGameVersion(request, request.adapter);
1048
1086
  const snapshot = await this.scan(request, [request.source?.path], usingManifest ? 'none' : request.source?.inlineClausewitz === undefined ? 'domain' : 'shared');
1087
+ const resolvedAdapter = usingManifest
1088
+ ? 'custom_weighted_pool'
1089
+ : resolveSourceBackedAdapter(snapshot, request.source, request.candidatePool ?? [], request.adapter);
1090
+ const gameVersionVerification = await this.verifyGameVersion(request, resolvedAdapter);
1049
1091
  const surface = usingManifest
1050
1092
  ? customSurface(request.customPoolManifest, snapshot.revision)
1051
- : discoverWeightedSurface(snapshot, request.adapter, request.source, request.candidatePool ?? []);
1093
+ : discoverWeightedSurface(snapshot, resolvedAdapter, request.source, request.candidatePool ?? []);
1052
1094
  const definitions = ClausewitzEvaluationDefinitions.build(snapshot);
1053
1095
  const metadata = this.metadata(request, snapshot, surface, request.scenarioSet, usingManifest
1054
1096
  ? request.customPoolManifest.candidates.map(({ id }) => id)
@@ -4,12 +4,17 @@ function byId(values) {
4
4
  }
5
5
  function placementSignature(values) {
6
6
  return hashCanonical(values
7
- .map(({ folderId, x, y, xExpression, yExpression }) => ({
7
+ .map(({ folderId, x, y, xExpression, yExpression, layoutSize, layoutWidth, layoutHeight, layoutOffsetX, layoutOffsetY, }) => ({
8
8
  folderId,
9
9
  x: x ?? null,
10
10
  y: y ?? null,
11
11
  xExpression: xExpression ?? null,
12
12
  yExpression: yExpression ?? null,
13
+ layoutSize: layoutSize ?? null,
14
+ layoutWidth: layoutWidth ?? null,
15
+ layoutHeight: layoutHeight ?? null,
16
+ layoutOffsetX: layoutOffsetX ?? null,
17
+ layoutOffsetY: layoutOffsetY ?? null,
13
18
  }))
14
19
  .sort((left, right) => compareCodeUnits(left.folderId, right.folderId) ||
15
20
  (left.x ?? 0) - (right.x ?? 0) ||
@@ -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.1';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;