hoi4-agent-tools 4.0.1 → 4.0.4

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 (64) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -4
  3. package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +5 -3
  4. package/dist/hoi4_agent_tools/core/condition-evaluator.js +5 -1
  5. package/dist/hoi4_agent_tools/core/index.js +11 -0
  6. package/dist/hoi4_agent_tools/core/job-executor.js +3 -1
  7. package/dist/hoi4_agent_tools/core/job-worker-host.js +1 -1
  8. package/dist/hoi4_agent_tools/core/render-progress.js +7 -0
  9. package/dist/hoi4_agent_tools/gui/layout.js +15 -8
  10. package/dist/hoi4_agent_tools/gui/scenario-generator.js +3 -3
  11. package/dist/hoi4_agent_tools/gui/source-graph.js +58 -26
  12. package/dist/hoi4_agent_tools/gui/studio.js +97 -31
  13. package/dist/hoi4_agent_tools/gui/tool-operations.js +8 -2
  14. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +2 -1
  15. package/dist/hoi4_agent_tools/mcp/server/job-task-store.js +3 -1
  16. package/dist/hoi4_agent_tools/mcp/server/modern-tasks.js +2 -0
  17. package/dist/hoi4_agent_tools/mcp/server/progress.js +12 -0
  18. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +2 -2
  19. package/dist/hoi4_agent_tools/mcp/server/task-request-context.js +5 -2
  20. package/dist/hoi4_agent_tools/technology/job-operations.js +1 -0
  21. package/dist/hoi4_agent_tools/technology/service.js +18 -37
  22. package/dist/hoi4_agent_tools/version.js +1 -1
  23. package/docs/gui.md +8 -0
  24. package/docs/technology.md +2 -0
  25. package/npm-shrinkwrap.json +2 -2
  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/decision-inspect-input.schema.json +1 -1
  31. package/schemas/error-log-input.schema.json +1 -1
  32. package/schemas/event-feature-manifest.schema.json +1 -1
  33. package/schemas/focus-plan.schema.json +1 -1
  34. package/schemas/focus-planning-sidecar.schema.json +1 -1
  35. package/schemas/gui-animation-source.schema.json +1 -1
  36. package/schemas/gui-helper.schema.json +1 -1
  37. package/schemas/gui-scenario.schema.json +1 -1
  38. package/schemas/helper-expansion-request.schema.json +1 -1
  39. package/schemas/helper-expansion-summary.schema.json +1 -1
  40. package/schemas/impact-inspect-input.schema.json +1 -1
  41. package/schemas/map-operation.schema.json +1 -1
  42. package/schemas/mechanic-test-input.schema.json +1 -1
  43. package/schemas/mod-index-input.schema.json +1 -1
  44. package/schemas/operation-result.schema.json +1 -1
  45. package/schemas/package-check-input.schema.json +1 -1
  46. package/schemas/package-manifest.schema.json +1 -1
  47. package/schemas/probability-analysis-result.schema.json +1 -1
  48. package/schemas/probability-compare-input.schema.json +1 -1
  49. package/schemas/probability-evaluate-input.schema.json +1 -1
  50. package/schemas/probability-inspect-input.schema.json +1 -1
  51. package/schemas/probability-render-input.schema.json +1 -1
  52. package/schemas/probability-scenario-set.schema.json +1 -1
  53. package/schemas/probability-sequence-input.schema.json +1 -1
  54. package/schemas/probability-simulate-input.schema.json +1 -1
  55. package/schemas/probability-sweep-input.schema.json +1 -1
  56. package/schemas/reference-context-input.schema.json +1 -1
  57. package/schemas/reference-read-input.schema.json +1 -1
  58. package/schemas/reference-search-input.schema.json +1 -1
  59. package/schemas/scenario-suite.schema.json +1 -1
  60. package/schemas/scenario-test-input.schema.json +1 -1
  61. package/schemas/script-validate-input.schema.json +1 -1
  62. package/schemas/shared-scenario.schema.json +1 -1
  63. package/schemas/source-lookup-input.schema.json +1 -1
  64. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 4.0.4 - 2026-10-14
6
+
7
+ - Preview individual GUI windows without redundant full symbol indexes, workspace-wide asset fallback or unbound country-flag inventories; reuse condition definitions across render variants.
8
+ - Index technology sources once across layers and scope folder rendering to definitions, layouts and unlock targets. Full inspection retains inbound grants and references.
9
+ - Report render phases and include the last phase in worker deadline errors. Existing parsing, raster-memory, canonical-path and stale-source limits remain enforced.
10
+ - Resolve transitive localisation `$KEY$` references across files and bounded scenario substitutions; retain diagnostics for cycles and missing runtime values.
11
+ - Honour numeric flag presence in scalar visibility checks, matching valued-flag super-event localisation conditions.
12
+ - Preserve supplied GUI scenarios by default; exploratory `generatedScenarios` is now opt-in. Explicit state/resolution matrices and public schema shapes are unchanged.
13
+ - Retain regression coverage for hidden sub-windows, timed animated-sprite frames, 16-bit DDS portraits, declared font colours, worker recovery and render-memory ceilings.
14
+
15
+ ## 4.0.3 - 2026-10-12
16
+
17
+ - Publish the README with the supplied HOI4 MCP Server thumbnail and direct links to the demo video on YouTube.
18
+ - Include the exact install command in the authored release page. Server behavior, tool schema shapes and dependency versions are unchanged from 4.0.1.
19
+
20
+ ## 4.0.2 - 2026-10-12
21
+
22
+ Not published: release validation stopped before publication because the authored page omitted its install command.
23
+
24
+ - Put the published demo video first in the README, using the supplied HOI4 MCP Server thumbnail and a direct link to the YouTube video.
25
+ - Use a public image URL so the thumbnail also loads on the npm package page.
26
+ - Documentation and release metadata only; server behavior, tool schemas and dependency versions are unchanged.
27
+
5
28
  ## 4.0.1 - 2026-10-12
6
29
 
7
30
  - Clean temporary test fixtures after each test, suite teardown and normal process exit. Reject fixture truncation above a 256 MiB aggregate disk budget before Windows allocates the file.
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # HOI4 Agent Tools
2
2
 
3
- <!-- TODO-VIDEO-LINK: replace with the promo video URL -->
4
-
5
- [![Watch the HOI4 Agent Tools demo](docs/images/agent-tools-demo-thumbnail.png)](https://www.youtube.com/@ChaosRedux)
3
+ [![Watch the HOI4 Agent Tools demo](https://raw.githubusercontent.com/klimPaskov/hoi4-agent-tools/main/docs/images/agent-tools-demo-thumbnail.png)](https://www.youtube.com/watch?v=Dd36E_NK4rQ)
6
4
 
7
5
  [![npm](https://img.shields.io/npm/v/hoi4-agent-tools)](https://www.npmjs.com/package/hoi4-agent-tools)
8
6
  [![Node.js](https://img.shields.io/node/v/hoi4-agent-tools)](https://nodejs.org/)
@@ -43,7 +41,7 @@ You describe what you want; the agent picks the right tools.
43
41
 
44
42
  The [setup guide](docs/setup.md) covers several mods, explicit game paths, local wiki snapshots and troubleshooting with `--discover` and `--diagnose`.
45
43
 
46
- [Watch the Agent Tools demo](https://github.com/klimPaskov/Agentic-HOI4-Modding/releases/latest/download/hoi4_agent_tools_promo.mp4) or use [HOI4 Mod Setup](https://github.com/klimPaskov/HOI4-Mod-Setup/releases/latest) to install and connect the server for your mod.
44
+ [Watch the Agent Tools demo](https://www.youtube.com/watch?v=Dd36E_NK4rQ) or use [HOI4 Mod Setup](https://github.com/klimPaskov/HOI4-Mod-Setup/releases/latest) to install and connect the server for your mod.
47
45
  Part of the HOI4 modding toolkit with [Agentic HOI4 Modding](https://github.com/klimPaskov/Agentic-HOI4-Modding) (skills and instructions for your agent) and [HOI4 Live Tools](https://github.com/klimPaskov/HOI4-Live-Tools) (testing in the running game).
48
46
 
49
47
  ## Examples
@@ -31,11 +31,13 @@ export class ClausewitzEvaluationDefinitions {
31
31
  scriptedTriggers = new Map();
32
32
  localConstants = new Map();
33
33
  static build(snapshot) {
34
- const cached = definitionCache.get(snapshot);
34
+ const cached = definitionCache.get(snapshot.files);
35
35
  if (cached !== undefined)
36
36
  return cached;
37
37
  const result = new ClausewitzEvaluationDefinitions();
38
- for (const file of snapshot.files.filter(({ shadowedBy, relativePath }) => shadowedBy === undefined && relativePath.toLowerCase().endsWith('.txt'))) {
38
+ for (const file of snapshot.files.filter(({ shadowedBy, relativePath, bytes }) => shadowedBy === undefined &&
39
+ relativePath.toLowerCase().endsWith('.txt') &&
40
+ bytes.includes(0x40))) {
39
41
  const document = parseClausewitz(file.bytes, file.displayPath);
40
42
  const locals = new Map();
41
43
  for (const assignment of scalarAssignments(document.root)) {
@@ -84,7 +86,7 @@ export class ClausewitzEvaluationDefinitions {
84
86
  });
85
87
  }
86
88
  }
87
- definitionCache.set(snapshot, result);
89
+ definitionCache.set(snapshot.files, result);
88
90
  return result;
89
91
  }
90
92
  }
@@ -286,7 +286,11 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
286
286
  : { state: value ? 'true' : 'false', unresolved: [] };
287
287
  }
288
288
  if (key === 'has_country_flag' || key === 'has_global_flag' || key === 'has_state_flag') {
289
- const explicit = bool(scopeStateValue(scenario, `flag.${right}`, scopeContext));
289
+ const stored = key === 'has_global_flag'
290
+ ? (scopeStateValue(scenario, `global_flag.${right}`, scopeContext) ??
291
+ scopeStateValue(scenario, `flag.${right}`, scopeContext))
292
+ : scopeStateValue(scenario, `flag.${right}`, scopeContext);
293
+ const explicit = typeof stored === 'number' && Number.isFinite(stored) ? true : bool(stored);
290
294
  if (explicit !== undefined)
291
295
  return { state: explicit ? 'true' : 'false', unresolved: [] };
292
296
  const flags = key === 'has_global_flag' && scenario.globalFlags !== undefined
@@ -383,6 +383,17 @@ export class SymbolIndex {
383
383
  },
384
384
  });
385
385
  }
386
+ /** Record parser coverage without allocating symbols for a domain that builds its own graph. */
387
+ recordSourceCoverage(file, diagnostics) {
388
+ const limits = sourcePartialLimitDiagnostics(diagnostics);
389
+ if (limits.length > 0) {
390
+ this.recordSkippedSource(file, limits);
391
+ return false;
392
+ }
393
+ if (isWorkspaceOwnedSource(file) && file.shadowedBy === undefined)
394
+ this.addDiagnostics(diagnostics);
395
+ return true;
396
+ }
386
397
  addSymbol(record) {
387
398
  if (this.symbols.length >= INDEX_RECORD_LIMIT) {
388
399
  this.#complete = false;
@@ -106,10 +106,11 @@ export class JobExecutor {
106
106
  const controller = new AbortController();
107
107
  const watchStop = new AbortController();
108
108
  let watchFailure;
109
+ let lastProgressMessage = claimed.record.progress?.message;
109
110
  const deadlineTimer = options.deadlineMs === undefined || operation.mutation
110
111
  ? undefined
111
112
  : setTimeout(() => {
112
- watchFailure = new ServiceError('JOB_DEADLINE_EXCEEDED', 'The job ran past its deadline and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds');
113
+ watchFailure = new ServiceError('JOB_DEADLINE_EXCEEDED', `The job ran past its deadline and was stopped${lastProgressMessage === undefined ? '' : ` during: ${lastProgressMessage.slice(0, 160)}`}; narrow its selector or limits, or raise jobDeadlineSeconds`);
113
114
  controller.abort(watchFailure);
114
115
  }, Math.max(0, options.deadlineMs - Date.now()));
115
116
  const watch = (async () => {
@@ -136,6 +137,7 @@ export class JobExecutor {
136
137
  signal: controller.signal,
137
138
  checkpoint: claimed.record.checkpoint,
138
139
  progress: async (progress) => {
140
+ lastProgressMessage = progress.message;
139
141
  await this.jobs.store.updateOwned(scope, id, owner.token, { progress }, controller.signal);
140
142
  },
141
143
  saveCheckpoint: async (checkpoint) => {
@@ -309,7 +309,7 @@ export class JobWorkerHost {
309
309
  await stopChild(child, this.stoppedByHost);
310
310
  await this.jobs.failInterrupted(workspaceId, id, {
311
311
  code: 'JOB_DEADLINE_EXCEEDED',
312
- message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds`,
312
+ message: `The job ran longer than ${jobDeadlineSeconds} seconds and was stopped${record.progress === undefined ? '' : ` during: ${record.progress.message.slice(0, 160)}`}; narrow its selector or limits, or raise jobDeadlineSeconds`,
313
313
  }, principal);
314
314
  return;
315
315
  }
@@ -0,0 +1,7 @@
1
+ export async function renderStage(input, phase) {
2
+ input.signal?.throwIfAborted();
3
+ await new Promise((resolve) => setImmediate(resolve));
4
+ input.signal?.throwIfAborted();
5
+ await input.progress?.(phase);
6
+ input.signal?.throwIfAborted();
7
+ }
@@ -219,15 +219,20 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
219
219
  /^[A-Za-z0-9_.-]+$/u.test(value) &&
220
220
  (value.includes('_') || value === value.toUpperCase()))
221
221
  missingLocalisation = true;
222
- text = replaceTextBounded(text, /\$([A-Za-z0-9_.-]+)\$/gu, (match) => {
222
+ const expandTokens = (input, stack, depth) => replaceTextBounded(input, /\$([A-Za-z0-9_.-]+)\$/gu, (match) => {
223
223
  const key = match[1] ?? '';
224
- const replacement = scenario.localisation[key] ?? localisation.get(key);
225
- if (replacement === undefined) {
226
- unresolved.push(`$${key}$`);
227
- return `$${key}$`;
224
+ const replacement = scenario.localisation[key] ??
225
+ localisation.get(key) ??
226
+ rowValues?.[key] ??
227
+ scenario.values[key] ??
228
+ scenario.variables[key];
229
+ if (replacement === undefined || stack.has(key) || depth >= 16) {
230
+ unresolved.push(match[0]);
231
+ return match[0];
228
232
  }
229
- return replacement;
233
+ return expandTokens(String(replacement), new Set([...stack, key]), depth + 1);
230
234
  }, 'GUI localisation substitution');
235
+ text = expandTokens(text, new Set([value]), 0);
231
236
  text = replaceTextBounded(text, /\[\?([^\]|]+)(?:\|[^\]]+)?\]/gu, (match) => {
232
237
  const key = match[1] ?? '';
233
238
  const shortKey = key.slice(key.lastIndexOf('.') + 1);
@@ -2026,7 +2031,9 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2026
2031
  const localisation = new Map(graph.localisation
2027
2032
  .filter((entry) => entry.language.toLowerCase() === scenario.language.toLowerCase() ||
2028
2033
  entry.language.toLowerCase() === `l_${scenario.language.toLowerCase()}`)
2029
- .map((entry) => [entry.key, entry.value]));
2034
+ // Graph localisation is ordered with the active (highest-priority) definition first.
2035
+ .map((entry) => [entry.key, entry.value])
2036
+ .reverse());
2030
2037
  if (localisation.size === 0) {
2031
2038
  for (const entry of graph.localisation)
2032
2039
  if (!localisation.has(entry.key))
@@ -2058,7 +2065,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2058
2065
  const countryFlagProperties = new Map();
2059
2066
  const conditionDefinitions = relevantScriptedGuiNames.size === 0
2060
2067
  ? undefined
2061
- : ClausewitzEvaluationDefinitions.build({ files: [...scannedFiles] });
2068
+ : ClausewitzEvaluationDefinitions.build({ files: scannedFiles });
2062
2069
  const conditionScenario = guiConditionScenario(scenario);
2063
2070
  for (const scripted of graph.scriptedGuis.filter(({ name }) => relevantScriptedGuiNames.has(name))) {
2064
2071
  const source = scannedFiles.find(({ displayPath }) => displayPath === scripted.sourcePath);
@@ -3,7 +3,7 @@ import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.j
3
3
  import { evaluateGuiCondition, guiConditionScenario, guiConditionVariables, guiExplicitConditionValues, } from './scenario-conditions.js';
4
4
  import { parsePreviewScenario } from './scenario.js';
5
5
  const countryNames = ['Germany', 'France', 'Poland', 'Italy', 'Brazil', 'India', 'Canada'];
6
- const countryTags = ['GER', 'FRA', 'POL', 'ITA', 'BRA', 'RAJ', 'CAN'];
6
+ export const generatedCountryTags = ['GER', 'FRA', 'POL', 'ITA', 'BRA', 'RAJ', 'CAN'];
7
7
  const countryIdeologies = [
8
8
  'fascism',
9
9
  'democratic',
@@ -435,7 +435,7 @@ function generatedRows(count, random, options) {
435
435
  return Array.from({ length: count }, (_unused, index) => {
436
436
  const countryIndex = (countryOffset + index) % countryNames.length;
437
437
  const name = countryNames[countryIndex];
438
- const tag = countryTags[countryIndex];
438
+ const tag = generatedCountryTags[countryIndex];
439
439
  const ideology = countryIdeologies[countryIndex];
440
440
  const value = generatedNumber(options, random);
441
441
  return {
@@ -587,7 +587,7 @@ export function generateGuiPreviewScenarios(graph, windowName, base, options, de
587
587
  const elementStates = { ...base.elementStates };
588
588
  const scopedCountries = new Map();
589
589
  const generatedCountryIndex = random.integer(0, countryNames.length - 1);
590
- const generatedCountryTag = countryTags[generatedCountryIndex];
590
+ const generatedCountryTag = generatedCountryTags[generatedCountryIndex];
591
591
  for (const key of tokens.numeric)
592
592
  setIfMissing(values, key, generatedNumberForKey(key, options, random, numericConstraints.ranges.get(key)));
593
593
  for (const [elementName, { minimum, maximum }] of progressRanges) {
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { compareCodeUnits, deterministicId } from '../core/canonical.js';
3
3
  import { DiagnosticCollector } from '../core/diagnostic-collector.js';
4
4
  import { sortDiagnostics } from '../core/diagnostics.js';
5
+ import { SymbolIndex } from '../core/index.js';
5
6
  import { ServiceError } from '../core/result.js';
6
7
  import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES } from './limits.js';
7
8
  import { assignments, childBlocks, firstScalar, locationFor, nodeLocation, parseClausewitz, parseLocalisation, } from '../core/source/index.js';
@@ -1306,7 +1307,8 @@ function referenceLabel(edge) {
1306
1307
  return String(value);
1307
1308
  return value === undefined ? edge.to : JSON.stringify(value);
1308
1309
  }
1309
- export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKeys = []) {
1310
+ export function buildGuiSourceGraph(files, providedIndex, additionalLocalisationKeys = [], limits = {}) {
1311
+ const sharedIndex = providedIndex ?? new SymbolIndex();
1310
1312
  const activeFiles = files.filter(({ shadowedBy }) => shadowedBy === undefined);
1311
1313
  const nodes = [];
1312
1314
  const edges = [];
@@ -1323,25 +1325,6 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1323
1325
  category: 'layout',
1324
1326
  message: 'GUI source graph diagnostics exceeded the fixed global result ceiling',
1325
1327
  });
1326
- diagnostics.pushMany(sharedIndex.diagnostics);
1327
- if (!sharedIndex.complete) {
1328
- diagnostics.push({
1329
- code: 'GUI_INVENTORY_PARTIAL',
1330
- severity: 'warning',
1331
- category: 'reference',
1332
- message: sharedIndex.skippedSourceCount === 0
1333
- ? 'The GUI inventory is incomplete because the shared index reached a fixed record limit'
1334
- : `The GUI inventory skipped ${sharedIndex.skippedSourceCount} over-limit source${sharedIndex.skippedSourceCount === 1 ? '' : 's'}`,
1335
- details: {
1336
- skippedSourceCount: sharedIndex.skippedSourceCount,
1337
- retainedSkippedSources: sharedIndex.skippedSources.length,
1338
- skippedSources: sharedIndex.skippedSources.slice(0, 10).map(({ path, reasonCodes }) => ({
1339
- path,
1340
- reasonCodes,
1341
- })),
1342
- },
1343
- });
1344
- }
1345
1328
  const sourceHashes = {};
1346
1329
  const textureNodes = new Map();
1347
1330
  const fontAssetNodes = new Map();
@@ -1423,13 +1406,20 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1423
1406
  if (kind === undefined || fileNodeId === undefined) {
1424
1407
  if (/\.(?:txt|gui|gfx)$/u.test(normalized)) {
1425
1408
  const document = parseClausewitz(file.bytes, file.displayPath);
1409
+ if (providedIndex === undefined &&
1410
+ !sharedIndex.recordSourceCoverage(file, document.diagnostics))
1411
+ continue;
1426
1412
  if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1427
1413
  diagnostics.pushMany(document.diagnostics);
1428
- indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1414
+ if (/^common\/decision(?:s|_categories)\//u.test(normalized))
1415
+ indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1429
1416
  }
1430
1417
  continue;
1431
1418
  }
1432
1419
  const document = parseClausewitz(file.bytes, file.displayPath);
1420
+ if (providedIndex === undefined &&
1421
+ !sharedIndex.recordSourceCoverage(file, document.diagnostics))
1422
+ continue;
1433
1423
  if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1434
1424
  diagnostics.pushMany(document.diagnostics);
1435
1425
  if (kind === 'gui_file')
@@ -1451,14 +1441,22 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1451
1441
  const referencedLocalisationKeys = referencedGuiLocalisationKeys(elements, scriptedLocalisation);
1452
1442
  for (const key of additionalLocalisationKeys)
1453
1443
  referencedLocalisationKeys.add(key);
1444
+ const maximumCandidates = Math.min(GUI_GRAPH_MAX_NODES, limits.maximumLocalisationCandidates ?? GUI_GRAPH_MAX_NODES);
1445
+ if (!Number.isSafeInteger(maximumCandidates) || maximumCandidates < 1)
1446
+ throw new ServiceError('GUI_LOCALISATION_BUDGET_INVALID', 'Localisation candidate limit must be a positive safe integer');
1447
+ let candidateCount = 0;
1448
+ const localisationCandidates = new Map();
1454
1449
  for (const { file, fileNodeId } of localisationFiles.sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
1455
1450
  compareCodeUnits(left.file.displayPath, right.file.displayPath))) {
1456
1451
  const document = parseLocalisation(file.bytes, file.displayPath);
1452
+ if (providedIndex === undefined &&
1453
+ !sharedIndex.recordSourceCoverage(file, document.diagnostics))
1454
+ continue;
1457
1455
  if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1458
1456
  diagnostics.pushMany(document.diagnostics);
1459
1457
  for (const entry of document.entries) {
1460
- if (!referencedLocalisationKeys.has(entry.key))
1461
- continue;
1458
+ if (++candidateCount > maximumCandidates)
1459
+ throw new ServiceError('GUI_LOCALISATION_BUDGET_BLOCKED', 'Localisation dependency inventory exceeds the fixed candidate ceiling', { maximumCandidates });
1462
1460
  const value = {
1463
1461
  key: entry.key,
1464
1462
  language: entry.language,
@@ -1466,21 +1464,55 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1466
1464
  sourcePath: file.displayPath,
1467
1465
  location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
1468
1466
  };
1467
+ const candidates = localisationCandidates.get(entry.key) ?? [];
1468
+ candidates.push({ value, fileNodeId });
1469
+ localisationCandidates.set(entry.key, candidates);
1470
+ }
1471
+ }
1472
+ const pendingKeys = [...referencedLocalisationKeys];
1473
+ for (const key of pendingKeys) {
1474
+ for (const { value, fileNodeId } of localisationCandidates.get(key) ?? []) {
1469
1475
  addDomainEntry(localisation, value, 'localisation');
1470
1476
  const id = deterministicId('gui_loc', value);
1471
1477
  addNode(nodes, {
1472
1478
  id,
1473
1479
  kind: 'localisation',
1474
- name: entry.key,
1475
- path: file.displayPath,
1480
+ name: key,
1481
+ path: value.sourcePath,
1476
1482
  ...(value.location === undefined ? {} : { location: value.location }),
1477
- metadata: { language: entry.language, value: entry.value },
1483
+ metadata: { language: value.language, value: value.value },
1478
1484
  });
1479
1485
  addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
1486
+ for (const match of value.value.matchAll(/\$([A-Za-z0-9_.-]+)(?:\|[^$]*)?\$/gu)) {
1487
+ const dependency = match[1];
1488
+ if (!referencedLocalisationKeys.has(dependency)) {
1489
+ referencedLocalisationKeys.add(dependency);
1490
+ pendingKeys.push(dependency);
1491
+ }
1492
+ }
1480
1493
  }
1481
1494
  }
1482
1495
  linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scriptedLocalisation, localisation, textureNodes, fontAssetNodes, pendingDecisionEdges);
1483
1496
  linkAnimationSources(nodes, edges, animationSources, sprites, textureNodes, animationFrameNodes);
1497
+ diagnostics.pushMany(sharedIndex.diagnostics);
1498
+ if (!sharedIndex.complete) {
1499
+ diagnostics.push({
1500
+ code: 'GUI_INVENTORY_PARTIAL',
1501
+ severity: 'warning',
1502
+ category: 'reference',
1503
+ message: sharedIndex.skippedSourceCount === 0
1504
+ ? 'The GUI inventory is incomplete because the shared index reached a fixed record limit'
1505
+ : `The GUI inventory skipped ${sharedIndex.skippedSourceCount} over-limit source${sharedIndex.skippedSourceCount === 1 ? '' : 's'}`,
1506
+ details: {
1507
+ skippedSourceCount: sharedIndex.skippedSourceCount,
1508
+ retainedSkippedSources: sharedIndex.skippedSources.length,
1509
+ skippedSources: sharedIndex.skippedSources.slice(0, 10).map(({ path, reasonCodes }) => ({
1510
+ path,
1511
+ reasonCodes,
1512
+ })),
1513
+ },
1514
+ });
1515
+ }
1484
1516
  markPartialReferenceEdges(edges, sharedIndex);
1485
1517
  diagnostics.pushMany(referenceDiagnostics(edges));
1486
1518
  if (nodes.length > GUI_GRAPH_MAX_NODES ||