hoi4-agent-tools 2.0.0 → 2.1.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 (37) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +4 -3
  3. package/dist/hoi4_agent_tools/core/artifacts.js +109 -4
  4. package/dist/hoi4_agent_tools/core/diagnostic-collector.js +28 -8
  5. package/dist/hoi4_agent_tools/core/diff.js +162 -4
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -0
  7. package/dist/hoi4_agent_tools/core/index.js +17 -6
  8. package/dist/hoi4_agent_tools/core/render-budget.js +5 -3
  9. package/dist/hoi4_agent_tools/core/scanner.js +3 -2
  10. package/dist/hoi4_agent_tools/core/source/limits.js +4 -1
  11. package/dist/hoi4_agent_tools/event/graph.js +28 -6
  12. package/dist/hoi4_agent_tools/event/limits.js +1 -1
  13. package/dist/hoi4_agent_tools/event/service.js +103 -7
  14. package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
  15. package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
  16. package/dist/hoi4_agent_tools/focus/render.js +27 -16
  17. package/dist/hoi4_agent_tools/gui/assets.js +9 -2
  18. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
  19. package/dist/hoi4_agent_tools/gui/layout.js +7 -3
  20. package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
  21. package/dist/hoi4_agent_tools/gui/studio.js +23 -8
  22. package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
  23. package/dist/hoi4_agent_tools/map/model.js +22 -4
  24. package/dist/hoi4_agent_tools/map/service.js +4 -3
  25. package/dist/hoi4_agent_tools/map/validation.js +3 -2
  26. package/dist/hoi4_agent_tools/mcp/server/create.js +1 -1
  27. package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
  28. package/dist/hoi4_agent_tools/mcp/tools/focus.js +145 -223
  29. package/dist/hoi4_agent_tools/mcp/tools/gui.js +29 -22
  30. package/dist/hoi4_agent_tools/version.js +1 -1
  31. package/docs/events.md +4 -5
  32. package/docs/focus.md +5 -4
  33. package/docs/gui.md +4 -2
  34. package/docs/map.md +1 -1
  35. package/docs/setup.md +1 -1
  36. package/package.json +1 -1
  37. package/server.json +2 -2
@@ -7,6 +7,8 @@ import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES, GUI_S
7
7
  import { guiElementAttributeFidelity } from './source-graph.js';
8
8
  import { emptyFidelityReport } from './types.js';
9
9
  const clickableTypes = /(?:button|checkbox|editbox|scrollbar|progressbar)/iu;
10
+ const numericDynamicPlaceholder = '[X]';
11
+ const textDynamicPlaceholder = '[dynamic_loc]';
10
12
  const partialSpriteSemantics = {
11
13
  textSpriteType: {
12
14
  field: 'text_sprite_semantics',
@@ -215,13 +217,15 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
215
217
  const replacement = rowValues?.[key] ?? scenario.variables[key] ?? scenario.scriptedGui[key];
216
218
  if (replacement === undefined) {
217
219
  unresolved.push(match[0]);
218
- return '[X]';
220
+ return /(?:^|\.)Get[A-Za-z0-9_]+$/u.test(key)
221
+ ? textDynamicPlaceholder
222
+ : numericDynamicPlaceholder;
219
223
  }
220
224
  return String(replacement);
221
225
  }, 'GUI variable substitution');
222
226
  text = replaceTextBounded(text, /\[([A-Za-z0-9_.:-]+)\]/gu, (match) => {
223
227
  const key = match[1] ?? '';
224
- if (key === 'X')
228
+ if (key === 'X' || key === 'dynamic_loc')
225
229
  return match[0];
226
230
  const countryKey = key.replace(/^(?:ROOT|This)\./u, '');
227
231
  const replacement = rowValues?.[countryKey] ??
@@ -230,7 +234,7 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
230
234
  scenario.scriptedGui[key];
231
235
  if (replacement === undefined) {
232
236
  unresolved.push(match[0]);
233
- return '[X]';
237
+ return textDynamicPlaceholder;
234
238
  }
235
239
  return String(replacement);
236
240
  }, 'GUI scope substitution');
@@ -736,19 +736,19 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
736
736
  }
737
737
  }
738
738
  for (const element of elements) {
739
- const spriteName = [element.attributes.spriteType, element.attributes.quadTextureSprite].find((value) => typeof value === 'string');
739
+ const spriteName = [element.attributes.spriteType, element.attributes.quadTextureSprite].find((value) => typeof value === 'string' && value.trim().length > 0);
740
740
  if (spriteName !== undefined) {
741
741
  const sprite = spriteByName.get(spriteName);
742
742
  addEdge(edges, 'uses_sprite', element.id, sprite?.id ?? `sprite:${spriteName}`, sprite !== undefined, { spriteName }, element.location);
743
743
  }
744
- const fontName = [element.attributes.font, element.attributes.buttonFont].find((value) => typeof value === 'string');
744
+ const fontName = [element.attributes.font, element.attributes.buttonFont].find((value) => typeof value === 'string' && value.trim().length > 0);
745
745
  if (fontName !== undefined) {
746
746
  const font = fontByName.get(fontName);
747
747
  addEdge(edges, 'uses_font', element.id, font?.id ?? `font:${fontName}`, font !== undefined, { fontName }, element.location);
748
748
  }
749
- for (const key of ['text', 'buttonText', 'pdx_tooltip', 'pdx_tooltip_delayed', 'hint_tag']) {
749
+ for (const key of localisationAttributeNames) {
750
750
  const value = element.attributes[key];
751
- if (typeof value !== 'string')
751
+ if (typeof value !== 'string' || value.trim().length === 0)
752
752
  continue;
753
753
  const entry = localisationByKey.get(value);
754
754
  addEdge(edges, 'uses_localisation', element.id, entry === undefined ? `localisation:${value}` : deterministicId('gui_loc', entry), entry !== undefined, { field: key, key: value }, element.location);
@@ -800,6 +800,24 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
800
800
  addEdge(edges, 'decision_category_entry', pending.from, target?.id ?? `scripted_gui:${pending.target}`, target !== undefined, { scriptedGui: pending.target }, pending.location);
801
801
  }
802
802
  }
803
+ const localisationAttributeNames = [
804
+ 'text',
805
+ 'buttonText',
806
+ 'pdx_tooltip',
807
+ 'pdx_tooltip_delayed',
808
+ 'hint_tag',
809
+ ];
810
+ function referencedGuiLocalisationKeys(elements, scriptedLocalisation) {
811
+ const keys = new Set(scriptedLocalisation.flatMap(({ localisationKeys }) => localisationKeys));
812
+ for (const element of elements) {
813
+ for (const attribute of localisationAttributeNames) {
814
+ const value = element.attributes[attribute];
815
+ if (typeof value === 'string' && value.trim().length > 0)
816
+ keys.add(value);
817
+ }
818
+ }
819
+ return keys;
820
+ }
803
821
  function skippedInventoryCouldResolve(edge, sharedIndex) {
804
822
  switch (edge.kind) {
805
823
  case 'uses_sprite':
@@ -837,6 +855,10 @@ function* referenceDiagnostics(edges) {
837
855
  edge.kind === 'animation_source_frame' ||
838
856
  (edge.kind === 'uses_font' && edge.metadata.assetPath !== undefined))
839
857
  continue;
858
+ if (edge.location !== undefined &&
859
+ !edge.location.path.startsWith('mod:') &&
860
+ !edge.location.path.startsWith('fixture:'))
861
+ continue;
840
862
  const partial = edge.partialInventory === true;
841
863
  yield {
842
864
  code: partial ? 'GUI_REFERENCE_UNRESOLVED_PARTIAL' : 'GUI_REFERENCE_UNRESOLVED',
@@ -905,6 +927,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
905
927
  const fontAssetNodes = new Map();
906
928
  const animationFrameNodes = new Map();
907
929
  const pendingDecisionEdges = [];
930
+ const localisationFiles = [];
908
931
  for (const file of [...activeFiles].sort((a, b) => compareCodeUnits(a.displayPath, b.displayPath))) {
909
932
  sourceHashes[file.displayPath] = file.sha256;
910
933
  if (sharedIndex.isSourceSkipped(file.displayPath))
@@ -968,28 +991,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
968
991
  }
969
992
  }
970
993
  if (kind === 'localisation_file' && fileNodeId !== undefined) {
971
- const document = parseLocalisation(file.bytes, file.displayPath);
972
- diagnostics.pushMany(document.diagnostics);
973
- for (const entry of document.entries) {
974
- const value = {
975
- key: entry.key,
976
- language: entry.language,
977
- value: entry.value,
978
- sourcePath: file.displayPath,
979
- location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
980
- };
981
- addDomainEntry(localisation, value, 'localisation');
982
- const id = deterministicId('gui_loc', value);
983
- addNode(nodes, {
984
- id,
985
- kind: 'localisation',
986
- name: entry.key,
987
- path: file.displayPath,
988
- ...(value.location === undefined ? {} : { location: value.location }),
989
- metadata: { language: entry.language, value: entry.value },
990
- });
991
- addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
992
- }
994
+ localisationFiles.push({ file, fileNodeId });
993
995
  continue;
994
996
  }
995
997
  if (kind === 'animation_source_manifest' && fileNodeId !== undefined) {
@@ -999,13 +1001,15 @@ export function buildGuiSourceGraph(files, sharedIndex) {
999
1001
  if (kind === undefined || fileNodeId === undefined) {
1000
1002
  if (/\.(?:txt|gui|gfx)$/u.test(normalized)) {
1001
1003
  const document = parseClausewitz(file.bytes, file.displayPath);
1002
- diagnostics.pushMany(document.diagnostics);
1004
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1005
+ diagnostics.pushMany(document.diagnostics);
1003
1006
  indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1004
1007
  }
1005
1008
  continue;
1006
1009
  }
1007
1010
  const document = parseClausewitz(file.bytes, file.displayPath);
1008
- diagnostics.pushMany(document.diagnostics);
1011
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1012
+ diagnostics.pushMany(document.diagnostics);
1009
1013
  if (kind === 'gui_file')
1010
1014
  indexGuiElements(document, file, fileNodeId, nodes, edges, elements);
1011
1015
  if (kind === 'gfx_file') {
@@ -1019,6 +1023,35 @@ export function buildGuiSourceGraph(files, sharedIndex) {
1019
1023
  }
1020
1024
  indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1021
1025
  }
1026
+ const referencedLocalisationKeys = referencedGuiLocalisationKeys(elements, scriptedLocalisation);
1027
+ for (const { file, fileNodeId } of localisationFiles.sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
1028
+ compareCodeUnits(left.file.displayPath, right.file.displayPath))) {
1029
+ const document = parseLocalisation(file.bytes, file.displayPath);
1030
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1031
+ diagnostics.pushMany(document.diagnostics);
1032
+ for (const entry of document.entries) {
1033
+ if (!referencedLocalisationKeys.has(entry.key))
1034
+ continue;
1035
+ const value = {
1036
+ key: entry.key,
1037
+ language: entry.language,
1038
+ value: entry.value,
1039
+ sourcePath: file.displayPath,
1040
+ location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
1041
+ };
1042
+ addDomainEntry(localisation, value, 'localisation');
1043
+ const id = deterministicId('gui_loc', value);
1044
+ addNode(nodes, {
1045
+ id,
1046
+ kind: 'localisation',
1047
+ name: entry.key,
1048
+ path: file.displayPath,
1049
+ ...(value.location === undefined ? {} : { location: value.location }),
1050
+ metadata: { language: entry.language, value: entry.value },
1051
+ });
1052
+ addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
1053
+ }
1054
+ }
1022
1055
  linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scriptedLocalisation, localisation, textureNodes, fontAssetNodes, pendingDecisionEdges);
1023
1056
  linkAnimationSources(nodes, edges, animationSources, sprites, textureNodes, animationFrameNodes);
1024
1057
  markPartialReferenceEdges(edges, sharedIndex);
@@ -125,7 +125,18 @@ function under(roots, suffixes) {
125
125
  return suffixes.map((suffix) => `${normalized}/${suffix}`);
126
126
  });
127
127
  }
128
- function guiDefinitionPatterns(workspace, languages = ['l_english']) {
128
+ function guiDefinitionPatterns(workspace) {
129
+ const roots = workspace.registration.roots;
130
+ return [
131
+ ...new Set([
132
+ ...under(roots.interface, ['**/*.gui', '**/*.gfx']),
133
+ ...under(roots.gfx, ['**/*.gfx']),
134
+ ...under(roots.scriptedGui, ['**/*.txt']),
135
+ ...staticGuiDefinitionPatterns,
136
+ ]),
137
+ ].sort((left, right) => compareCodeUnits(left, right));
138
+ }
139
+ function guiLocalisationPatterns(workspace, languages = ['l_english']) {
129
140
  const roots = workspace.registration.roots;
130
141
  const languageDirectories = [...new Set(languages)].map((language) => {
131
142
  if (!/^l_[a-z_]+$/u.test(language)) {
@@ -135,9 +146,6 @@ function guiDefinitionPatterns(workspace, languages = ['l_english']) {
135
146
  });
136
147
  return [
137
148
  ...new Set([
138
- ...under(roots.interface, ['**/*.gui', '**/*.gfx']),
139
- ...under(roots.gfx, ['**/*.gfx']),
140
- ...under(roots.scriptedGui, ['**/*.txt']),
141
149
  ...under(roots.localisation, [
142
150
  '*.{yml,yaml}',
143
151
  ...languageDirectories.flatMap((directory) => [
@@ -145,7 +153,6 @@ function guiDefinitionPatterns(workspace, languages = ['l_english']) {
145
153
  `${directory}/**/*.yaml`,
146
154
  ]),
147
155
  ]),
148
- ...staticGuiDefinitionPatterns,
149
156
  ]),
150
157
  ].sort((left, right) => compareCodeUnits(left, right));
151
158
  }
@@ -395,11 +402,18 @@ export class ScriptedGuiStudio {
395
402
  async scan(workspaceId, principal, signal, languages = ['l_english']) {
396
403
  signal?.throwIfAborted();
397
404
  const workspace = this.resolver.get(workspaceId, principal);
398
- const snapshot = await this.engine.scan(workspaceId, { patterns: guiDefinitionPatterns(workspace, languages) }, principal, signal);
405
+ const [snapshot, localisationFiles] = await Promise.all([
406
+ this.engine.scan(workspaceId, { patterns: guiDefinitionPatterns(workspace) }, principal, signal),
407
+ this.scanner.scan(workspace, {
408
+ patterns: guiLocalisationPatterns(workspace, languages),
409
+ ...(signal === undefined ? {} : { signal }),
410
+ }),
411
+ ]);
399
412
  signal?.throwIfAborted();
413
+ const files = mergeScannedFiles(snapshot.files, localisationFiles);
400
414
  return {
401
- files: snapshot.files,
402
- graph: buildGuiSourceGraph(snapshot.files, snapshot.index),
415
+ files,
416
+ graph: buildGuiSourceGraph(files, snapshot.index),
403
417
  };
404
418
  }
405
419
  async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english']) {
@@ -599,6 +613,7 @@ export class ScriptedGuiStudio {
599
613
  const stored = await this.artifacts.withAtomicChunkedWrites(workspace, writes, (artifacts) => Promise.resolve([...artifacts]), input.signal);
600
614
  return {
601
615
  artifacts: stored,
616
+ filesScanned: scanned.graph.filesScanned,
602
617
  render,
603
618
  stateScenes,
604
619
  resolutionScenes,
@@ -1,18 +1,99 @@
1
1
  export const MAP_DIAGNOSTIC_LIMIT = 2_000;
2
- export function addMapDiagnostic(diagnostics, diagnostic) {
3
- if (diagnostics.at(-1)?.code === 'MAP_DIAGNOSTICS_TRUNCATED')
2
+ function isHardDiagnostic(diagnostic) {
3
+ return diagnostic.severity === 'error' || diagnostic.severity === 'blocker';
4
+ }
5
+ function isWorkspacePath(path) {
6
+ return path.startsWith('mod:') || path.startsWith('fixture:');
7
+ }
8
+ function isBaselineOnlyDiagnostic(diagnostic) {
9
+ if (diagnostic.location === undefined || isWorkspacePath(diagnostic.location.path))
10
+ return false;
11
+ return !(diagnostic.related ?? []).some(({ path }) => isWorkspacePath(path));
12
+ }
13
+ function addOmittedDiagnostic(details, diagnostic) {
14
+ details.omitted += 1;
15
+ details.omittedBySeverity[diagnostic.severity] =
16
+ (details.omittedBySeverity[diagnostic.severity] ?? 0) + 1;
17
+ details.omittedByCode[diagnostic.code] = (details.omittedByCode[diagnostic.code] ?? 0) + 1;
18
+ }
19
+ function truncationDiagnostic(omitted) {
20
+ const details = {
21
+ limit: MAP_DIAGNOSTIC_LIMIT,
22
+ retained: MAP_DIAGNOSTIC_LIMIT - 1,
23
+ omitted: 0,
24
+ omittedBySeverity: {},
25
+ omittedByCode: {},
26
+ };
27
+ for (const diagnostic of omitted)
28
+ addOmittedDiagnostic(details, diagnostic);
29
+ const hardOmitted = omitted.some(isHardDiagnostic);
30
+ return {
31
+ code: 'MAP_DIAGNOSTICS_TRUNCATED',
32
+ severity: hardOmitted ? 'blocker' : 'info',
33
+ category: 'map',
34
+ message: hardOmitted
35
+ ? 'Map diagnostic detail exceeded the result ceiling and omitted an error or blocker'
36
+ : 'Map diagnostic detail exceeded the result ceiling; deterministic samples and omission counts were retained',
37
+ details,
38
+ };
39
+ }
40
+ function existingTruncationDiagnostic(diagnostics) {
41
+ const marker = diagnostics.at(-1);
42
+ return marker?.code === 'MAP_DIAGNOSTICS_TRUNCATED'
43
+ ? marker
44
+ : undefined;
45
+ }
46
+ function addBoundedDiagnostic(diagnostics, diagnostic, includeBaseline) {
47
+ if (!includeBaseline && isBaselineOnlyDiagnostic(diagnostic))
48
+ return;
49
+ const marker = existingTruncationDiagnostic(diagnostics);
50
+ if (marker !== undefined) {
51
+ if (isHardDiagnostic(diagnostic)) {
52
+ const replaceIndex = diagnostics
53
+ .slice(0, -1)
54
+ .findLastIndex((retained) => !isHardDiagnostic(retained));
55
+ if (replaceIndex >= 0) {
56
+ const replaced = diagnostics[replaceIndex];
57
+ diagnostics[replaceIndex] = diagnostic;
58
+ addOmittedDiagnostic(marker.details, replaced);
59
+ return;
60
+ }
61
+ marker.severity = 'blocker';
62
+ marker.message =
63
+ 'Map diagnostic detail exceeded the result ceiling and omitted an error or blocker';
64
+ }
65
+ addOmittedDiagnostic(marker.details, diagnostic);
4
66
  return;
67
+ }
5
68
  if (diagnostics.length < MAP_DIAGNOSTIC_LIMIT) {
6
69
  diagnostics.push(diagnostic);
7
70
  return;
8
71
  }
9
- diagnostics[MAP_DIAGNOSTIC_LIMIT - 1] = {
10
- code: 'MAP_DIAGNOSTICS_TRUNCATED',
11
- severity: 'blocker',
12
- category: 'map',
13
- message: 'Map diagnostics exceeded the configured output limit',
14
- details: { limit: MAP_DIAGNOSTIC_LIMIT, retained: MAP_DIAGNOSTIC_LIMIT - 1 },
15
- };
72
+ const candidates = [...diagnostics, diagnostic];
73
+ const hard = candidates.filter(isHardDiagnostic);
74
+ const soft = candidates.filter((candidate) => !isHardDiagnostic(candidate));
75
+ const retained = hard.length >= MAP_DIAGNOSTIC_LIMIT - 1
76
+ ? hard.slice(0, MAP_DIAGNOSTIC_LIMIT - 1)
77
+ : [...hard, ...soft.slice(0, MAP_DIAGNOSTIC_LIMIT - 1 - hard.length)];
78
+ const retainedCounts = new Map();
79
+ for (const item of retained)
80
+ retainedCounts.set(item, (retainedCounts.get(item) ?? 0) + 1);
81
+ const omitted = candidates.filter((candidate) => {
82
+ const count = retainedCounts.get(candidate) ?? 0;
83
+ if (count === 0)
84
+ return true;
85
+ retainedCounts.set(candidate, count - 1);
86
+ return false;
87
+ });
88
+ diagnostics.splice(0, diagnostics.length, ...retained, truncationDiagnostic(omitted));
89
+ }
90
+ export function addMapDiagnostic(diagnostics, diagnostic) {
91
+ addBoundedDiagnostic(diagnostics, diagnostic, false);
92
+ }
93
+ /** Active topology validation must report failures in whichever source wins
94
+ * precedence, including inherited game and dependency files. */
95
+ export function addActiveMapDiagnostic(diagnostics, diagnostic) {
96
+ addBoundedDiagnostic(diagnostics, diagnostic, true);
16
97
  }
17
98
  export function addMapDiagnostics(diagnostics, additions) {
18
99
  for (const diagnostic of additions)
@@ -24,11 +24,13 @@ const MAP_TEXT_FIELD_LIMIT = 10_000;
24
24
  const MAP_MODEL_MAX_SCRIPT_FILES = 5_000;
25
25
  const MAP_MODEL_MAX_SOURCE_TOKENS = 1_000_000;
26
26
  const MAP_MODEL_MAX_RECORDS = 500_000;
27
+ const MAP_MODEL_MAX_LOCALISATION_RECORDS = 2_000_000;
27
28
  const MAP_MODEL_MAX_MEMBERSHIP_EDGES = 1_000_000;
28
29
  class MapModelBudget {
29
30
  #documents = new Set();
30
31
  #tokens = 0;
31
32
  #records = 0;
33
+ #localisationRecords = 0;
32
34
  #membershipEdges = 0;
33
35
  assertScriptFiles(count) {
34
36
  if (count > MAP_MODEL_MAX_SCRIPT_FILES) {
@@ -50,6 +52,13 @@ class MapModelBudget {
50
52
  this.block('domain records', this.#records, MAP_MODEL_MAX_RECORDS);
51
53
  }
52
54
  }
55
+ addLocalisationRecords(count) {
56
+ this.#localisationRecords += count;
57
+ if (!Number.isSafeInteger(this.#localisationRecords) ||
58
+ this.#localisationRecords > MAP_MODEL_MAX_LOCALISATION_RECORDS) {
59
+ this.block('localisation records', this.#localisationRecords, MAP_MODEL_MAX_LOCALISATION_RECORDS);
60
+ }
61
+ }
53
62
  addMembershipEdges(count) {
54
63
  this.#membershipEdges += count;
55
64
  if (!Number.isSafeInteger(this.#membershipEdges) ||
@@ -869,7 +878,9 @@ export class MapWorkspaceIndex {
869
878
  const modelBudget = new MapModelBudget();
870
879
  modelBudget.assertScriptFiles(this.sourceFiles.filter((file) => inConfiguredRoot(file.relativePath, this.sourceRoots.states) ||
871
880
  this.sourceRoots.map.some((root) => normalizedPath(file.relativePath).startsWith(`${root}/strategicregions/`)) ||
872
- normalizedPath(file.relativePath).endsWith('.asset')).length);
881
+ ((file.rootKind === 'mod' || file.rootKind === 'fixture') &&
882
+ normalizedPath(file.relativePath).endsWith('.asset') &&
883
+ file.bytes.includes('locator'))).length);
873
884
  this.defaultMapFile = fileInRoots(this.activeFiles, this.sourceRoots.map, 'default.map');
874
885
  const definitionsName = defaultMapSelectorValue(this.defaultMapFile, 'definitions', 'definition.csv');
875
886
  const provincesName = defaultMapSelectorValue(this.defaultMapFile, 'provinces', 'provinces.bmp');
@@ -1031,7 +1042,9 @@ export class MapWorkspaceIndex {
1031
1042
  weatherFile === undefined ? [] : parseWeatherPositions(weatherFile, this.diagnostics);
1032
1043
  modelBudget.addRecords(this.weatherPositions.length);
1033
1044
  this.entityLocators = this.activeFiles.all
1034
- .filter((file) => normalizedPath(file.relativePath).endsWith('.asset'))
1045
+ .filter((file) => (file.rootKind === 'mod' || file.rootKind === 'fixture') &&
1046
+ normalizedPath(file.relativePath).endsWith('.asset') &&
1047
+ file.bytes.includes('locator'))
1035
1048
  .flatMap((file) => {
1036
1049
  const locators = parseEntityLocators(file, this.diagnostics);
1037
1050
  if (locators[0] !== undefined)
@@ -1042,8 +1055,13 @@ export class MapWorkspaceIndex {
1042
1055
  for (const file of this.activeFiles.all.filter((entry) => inConfiguredRoot(entry.relativePath, this.sourceRoots.localisation) &&
1043
1056
  normalizedPath(entry.relativePath).endsWith('.yml'))) {
1044
1057
  const document = parseLocalisation(file.bytes, file.displayPath);
1045
- addMapDiagnostics(this.diagnostics, document.diagnostics);
1046
- modelBudget.addRecords(document.entries.length);
1058
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture') {
1059
+ addMapDiagnostics(this.diagnostics, document.diagnostics);
1060
+ }
1061
+ // Localisation is a supporting lookup table rather than connected map topology. Account for
1062
+ // it separately so a normal full-game language database cannot exhaust the domain-record
1063
+ // budget before provinces, states, regions, railways, and adjacencies are available.
1064
+ modelBudget.addLocalisationRecords(document.entries.length);
1047
1065
  for (const entry of document.entries) {
1048
1066
  this.localisationKeys.add(`${entry.language}:${entry.key}`);
1049
1067
  const indexed = { file, document, entry };
@@ -4,6 +4,7 @@ import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '.
4
4
  import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
5
5
  import { sortDiagnostics } from '../core/diagnostics.js';
6
6
  import { CoreEngine } from '../core/engine.js';
7
+ import { SymbolIndex } from '../core/index.js';
7
8
  import { RenderBudget } from '../core/render-budget.js';
8
9
  import { ServiceError } from '../core/result.js';
9
10
  import { WorkspaceScanner } from '../core/scanner.js';
@@ -386,7 +387,6 @@ function sourceTextPatterns(roots) {
386
387
  const normalized = normalizeSourceRoot(root);
387
388
  return [`${normalized}/english/**/*.{yml,yaml}`, `${normalized}/*.{yml,yaml}`];
388
389
  }),
389
- '**/*.asset',
390
390
  ];
391
391
  }
392
392
  function provinceBitmapPatterns(defaultMaps, mapRoots) {
@@ -491,14 +491,15 @@ export class AgentNudger {
491
491
  const selectorSnapshot = await this.engine.scan(workspaceId, { patterns: defaultMapPatterns(sourceRoots) }, principal, signal);
492
492
  const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map);
493
493
  const contentSnapshot = await this.engine.scan(workspaceId, { patterns: [...sourceTextPatterns(sourceRoots), ...bitmapPatterns] }, principal, signal);
494
+ const locatorSnapshot = await this.engine.scan(workspaceId, { patterns: ['**/*.asset'], rootKinds: ['mod', 'fixture'] }, principal, signal);
494
495
  signal?.throwIfAborted();
495
- const files = contentSnapshot.files;
496
+ const files = [...contentSnapshot.files, ...locatorSnapshot.files].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
496
497
  const revision = hashCanonical(files.map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
497
498
  return {
498
499
  workspaceId,
499
500
  revision,
500
501
  files,
501
- index: MapWorkspaceIndex.build(files, sourceRoots, contentSnapshot.index),
502
+ index: MapWorkspaceIndex.build(files, sourceRoots, SymbolIndex.build(files)),
502
503
  };
503
504
  }
504
505
  async validate(workspaceId, principal, signal) {
@@ -1,7 +1,7 @@
1
1
  import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
2
2
  import { sortDiagnostics } from '../core/diagnostics.js';
3
3
  import { assertRenderDimensions } from '../core/render-budget.js';
4
- import { addMapDiagnostic as addBoundedMapDiagnostic } from './diagnostic-limit.js';
4
+ import { addActiveMapDiagnostic, addMapDiagnostic } from './diagnostic-limit.js';
5
5
  import { rgbKey } from './bmp.js';
6
6
  import { MAP_PROVINCE_ENGINE_MAX_PIXELS } from './model.js';
7
7
  const MAP_DIAGNOSTIC_DETAIL_SAMPLE_LIMIT = 100;
@@ -53,7 +53,8 @@ function definitionLocation(definition) {
53
53
  return requiredLineLocation(definition.document, definition.line);
54
54
  }
55
55
  function addDiagnostic(diagnostics, options, diagnostic) {
56
- addBoundedMapDiagnostic(diagnostics, {
56
+ const add = options.includeBaselineDiagnostics === true ? addActiveMapDiagnostic : addMapDiagnostic;
57
+ add(diagnostics, {
57
58
  ...diagnostic,
58
59
  ...(diagnostic.operationId !== undefined || options.operationId === undefined
59
60
  ? {}
@@ -5,7 +5,7 @@ import { registerGuiTools } from '../tools/gui.js';
5
5
  import { registerMapTools } from '../tools/map.js';
6
6
  import { registerEventTools } from '../tools/event.js';
7
7
  import { registerMcpResources } from '../resources/register.js';
8
- export const SERVER_INSTRUCTIONS = 'The workspace defaults to the mod containing the MCP working directory; pass workspaceId only when selecting another configured mod. Use hoi4.event_inspect for scan, roots, trace, explain_path, state_flow, lint, and impact; hoi4.event_render and hoi4.event_compare return linked evidence. JSON resources are authoritative. Event tools are read-only. Only focus, GUI, and map rewrite tools edit configured mods.';
8
+ export const SERVER_INSTRUCTIONS = 'Use hoi4.focus_inspect and hoi4.focus_render for fast structural work; hoi4.focus_raster adds decoded icons and PNG output. Use hoi4.event_inspect to analyze event chains. Event tools are read-only. Focus, GUI, and map rewrite tools edit the current mod. Large evidence is returned through linked resources.';
9
9
  export function createMcpServer(engine, context = {}) {
10
10
  const server = new McpServer({
11
11
  name: PACKAGE_NAME,
@@ -16,7 +16,7 @@ export const renderHashesSchema = z
16
16
  .object({
17
17
  html: sha256Schema,
18
18
  svg: sha256Schema,
19
- png: sha256Schema,
19
+ png: sha256Schema.optional(),
20
20
  json: sha256Schema,
21
21
  })
22
22
  .strict();