hoi4-agent-tools 4.0.3 → 4.0.5

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 +18 -0
  2. package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +5 -3
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +5 -1
  4. package/dist/hoi4_agent_tools/core/index.js +11 -0
  5. package/dist/hoi4_agent_tools/core/job-executor.js +3 -1
  6. package/dist/hoi4_agent_tools/core/job-worker-host.js +1 -1
  7. package/dist/hoi4_agent_tools/core/render-progress.js +7 -0
  8. package/dist/hoi4_agent_tools/gui/layout.js +76 -33
  9. package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
  10. package/dist/hoi4_agent_tools/gui/scenario-generator.js +3 -3
  11. package/dist/hoi4_agent_tools/gui/source-graph.js +67 -32
  12. package/dist/hoi4_agent_tools/gui/studio.js +101 -32
  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 +14 -2
  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
@@ -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';
@@ -118,7 +119,7 @@ export function guiElementAttributeFidelity(key, elementType) {
118
119
  if (elementType?.toLowerCase() === 'extendedscrollbartype' &&
119
120
  ['horizontal', 'slider', 'track', 'decreaseButton', 'increaseButton'].some((candidate) => candidate.toLowerCase() === key.toLowerCase()))
120
121
  return 'modelled';
121
- if (/^(?:containerwindowtype|windowtype)$/iu.test(elementType ?? '') &&
122
+ if (/^(?:containerwindowtype|windowtype|expandedwindow)$/iu.test(elementType ?? '') &&
122
123
  ['verticalScrollbar', 'horizontalScrollbar', 'autohide_scrollbars', 'margin'].some((candidate) => candidate.toLowerCase() === key.toLowerCase()))
123
124
  return 'structural';
124
125
  if (elementType?.toLowerCase() === 'scrollbartype' &&
@@ -297,9 +298,12 @@ function nestedSizeFrom(block, key) {
297
298
  function raw(document, node) {
298
299
  return document.text.slice(node.start, node.end);
299
300
  }
300
- function isGuiElement(assignment) {
301
+ function isGuiElement(assignment, parentType) {
301
302
  if (assignment.value.type !== 'block')
302
303
  return false;
304
+ if (parentType?.toLowerCase() === 'dropdownboxtype' &&
305
+ /^(?:expandedwindow|expandbutton)$/iu.test(assignment.key.value))
306
+ return true;
303
307
  if (explicitGuiElementTypes.has(assignment.key.value.toLowerCase()))
304
308
  return true;
305
309
  return (/type$/iu.test(assignment.key.value) && firstScalar(assignment.value, 'name') !== undefined);
@@ -362,11 +366,11 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
362
366
  const constants = constantsFor(document);
363
367
  const firstFileElement = elements.length;
364
368
  let order = 0;
365
- const walk = (block, parentId) => {
369
+ const walk = (block, parentId, parentType) => {
366
370
  for (const assignment of assignments(block)) {
367
371
  if (assignment.value.type !== 'block')
368
372
  continue;
369
- if (isGuiElement(assignment)) {
373
+ if (isGuiElement(assignment, parentType)) {
370
374
  const name = firstScalar(assignment.value, 'name')?.value;
371
375
  if (name === undefined)
372
376
  continue;
@@ -385,7 +389,7 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
385
389
  childIds: [],
386
390
  attributes: attributes,
387
391
  unsupportedAttributes: assignments(assignment.value)
388
- .filter((child) => !isGuiElement(child))
392
+ .filter((child) => !isGuiElement(child, assignment.key.value))
389
393
  .map(({ key }) => key.value)
390
394
  .filter((key) => guiElementAttributeFidelity(key, assignment.key.value) === 'unsupported')
391
395
  .sort((a, b) => compareCodeUnits(a, b)),
@@ -408,7 +412,7 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
408
412
  addEdge(edges, 'contains', parentId ?? fileNodeId, id, true, {}, element.location);
409
413
  if (parentId !== undefined)
410
414
  addEdge(edges, 'parent', id, parentId, true, {}, element.location);
411
- walk(assignment.value, id);
415
+ walk(assignment.value, id, assignment.key.value);
412
416
  }
413
417
  else {
414
418
  walk(assignment.value, parentId);
@@ -1306,7 +1310,8 @@ function referenceLabel(edge) {
1306
1310
  return String(value);
1307
1311
  return value === undefined ? edge.to : JSON.stringify(value);
1308
1312
  }
1309
- export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKeys = []) {
1313
+ export function buildGuiSourceGraph(files, providedIndex, additionalLocalisationKeys = [], limits = {}) {
1314
+ const sharedIndex = providedIndex ?? new SymbolIndex();
1310
1315
  const activeFiles = files.filter(({ shadowedBy }) => shadowedBy === undefined);
1311
1316
  const nodes = [];
1312
1317
  const edges = [];
@@ -1323,25 +1328,6 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1323
1328
  category: 'layout',
1324
1329
  message: 'GUI source graph diagnostics exceeded the fixed global result ceiling',
1325
1330
  });
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
1331
  const sourceHashes = {};
1346
1332
  const textureNodes = new Map();
1347
1333
  const fontAssetNodes = new Map();
@@ -1423,13 +1409,20 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1423
1409
  if (kind === undefined || fileNodeId === undefined) {
1424
1410
  if (/\.(?:txt|gui|gfx)$/u.test(normalized)) {
1425
1411
  const document = parseClausewitz(file.bytes, file.displayPath);
1412
+ if (providedIndex === undefined &&
1413
+ !sharedIndex.recordSourceCoverage(file, document.diagnostics))
1414
+ continue;
1426
1415
  if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1427
1416
  diagnostics.pushMany(document.diagnostics);
1428
- indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1417
+ if (/^common\/decision(?:s|_categories)\//u.test(normalized))
1418
+ indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
1429
1419
  }
1430
1420
  continue;
1431
1421
  }
1432
1422
  const document = parseClausewitz(file.bytes, file.displayPath);
1423
+ if (providedIndex === undefined &&
1424
+ !sharedIndex.recordSourceCoverage(file, document.diagnostics))
1425
+ continue;
1433
1426
  if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1434
1427
  diagnostics.pushMany(document.diagnostics);
1435
1428
  if (kind === 'gui_file')
@@ -1451,14 +1444,22 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1451
1444
  const referencedLocalisationKeys = referencedGuiLocalisationKeys(elements, scriptedLocalisation);
1452
1445
  for (const key of additionalLocalisationKeys)
1453
1446
  referencedLocalisationKeys.add(key);
1447
+ const maximumCandidates = Math.min(GUI_GRAPH_MAX_NODES, limits.maximumLocalisationCandidates ?? GUI_GRAPH_MAX_NODES);
1448
+ if (!Number.isSafeInteger(maximumCandidates) || maximumCandidates < 1)
1449
+ throw new ServiceError('GUI_LOCALISATION_BUDGET_INVALID', 'Localisation candidate limit must be a positive safe integer');
1450
+ let candidateCount = 0;
1451
+ const localisationCandidates = new Map();
1454
1452
  for (const { file, fileNodeId } of localisationFiles.sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
1455
1453
  compareCodeUnits(left.file.displayPath, right.file.displayPath))) {
1456
1454
  const document = parseLocalisation(file.bytes, file.displayPath);
1455
+ if (providedIndex === undefined &&
1456
+ !sharedIndex.recordSourceCoverage(file, document.diagnostics))
1457
+ continue;
1457
1458
  if (file.rootKind === 'mod' || file.rootKind === 'fixture')
1458
1459
  diagnostics.pushMany(document.diagnostics);
1459
1460
  for (const entry of document.entries) {
1460
- if (!referencedLocalisationKeys.has(entry.key))
1461
- continue;
1461
+ if (++candidateCount > maximumCandidates)
1462
+ throw new ServiceError('GUI_LOCALISATION_BUDGET_BLOCKED', 'Localisation dependency inventory exceeds the fixed candidate ceiling', { maximumCandidates });
1462
1463
  const value = {
1463
1464
  key: entry.key,
1464
1465
  language: entry.language,
@@ -1466,21 +1467,55 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
1466
1467
  sourcePath: file.displayPath,
1467
1468
  location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
1468
1469
  };
1470
+ const candidates = localisationCandidates.get(entry.key) ?? [];
1471
+ candidates.push({ value, fileNodeId });
1472
+ localisationCandidates.set(entry.key, candidates);
1473
+ }
1474
+ }
1475
+ const pendingKeys = [...referencedLocalisationKeys];
1476
+ for (const key of pendingKeys) {
1477
+ for (const { value, fileNodeId } of localisationCandidates.get(key) ?? []) {
1469
1478
  addDomainEntry(localisation, value, 'localisation');
1470
1479
  const id = deterministicId('gui_loc', value);
1471
1480
  addNode(nodes, {
1472
1481
  id,
1473
1482
  kind: 'localisation',
1474
- name: entry.key,
1475
- path: file.displayPath,
1483
+ name: key,
1484
+ path: value.sourcePath,
1476
1485
  ...(value.location === undefined ? {} : { location: value.location }),
1477
- metadata: { language: entry.language, value: entry.value },
1486
+ metadata: { language: value.language, value: value.value },
1478
1487
  });
1479
1488
  addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
1489
+ for (const match of value.value.matchAll(/\$([A-Za-z0-9_.-]+)(?:\|[^$]*)?\$/gu)) {
1490
+ const dependency = match[1];
1491
+ if (!referencedLocalisationKeys.has(dependency)) {
1492
+ referencedLocalisationKeys.add(dependency);
1493
+ pendingKeys.push(dependency);
1494
+ }
1495
+ }
1480
1496
  }
1481
1497
  }
1482
1498
  linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scriptedLocalisation, localisation, textureNodes, fontAssetNodes, pendingDecisionEdges);
1483
1499
  linkAnimationSources(nodes, edges, animationSources, sprites, textureNodes, animationFrameNodes);
1500
+ diagnostics.pushMany(sharedIndex.diagnostics);
1501
+ if (!sharedIndex.complete) {
1502
+ diagnostics.push({
1503
+ code: 'GUI_INVENTORY_PARTIAL',
1504
+ severity: 'warning',
1505
+ category: 'reference',
1506
+ message: sharedIndex.skippedSourceCount === 0
1507
+ ? 'The GUI inventory is incomplete because the shared index reached a fixed record limit'
1508
+ : `The GUI inventory skipped ${sharedIndex.skippedSourceCount} over-limit source${sharedIndex.skippedSourceCount === 1 ? '' : 's'}`,
1509
+ details: {
1510
+ skippedSourceCount: sharedIndex.skippedSourceCount,
1511
+ retainedSkippedSources: sharedIndex.skippedSources.length,
1512
+ skippedSources: sharedIndex.skippedSources.slice(0, 10).map(({ path, reasonCodes }) => ({
1513
+ path,
1514
+ reasonCodes,
1515
+ })),
1516
+ },
1517
+ });
1518
+ }
1484
1519
  markPartialReferenceEdges(edges, sharedIndex);
1485
1520
  diagnostics.pushMany(referenceDiagnostics(edges));
1486
1521
  if (nodes.length > GUI_GRAPH_MAX_NODES ||
@@ -9,6 +9,7 @@ import { CoreEngine } from '../core/engine.js';
9
9
  import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
10
10
  import { assertRenderDimensions, RenderBudget, RENDER_MAX_PIXELS } from '../core/render-budget.js';
11
11
  import { escapeFileGlob } from '../core/file-glob.js';
12
+ import { renderStage } from '../core/render-progress.js';
12
13
  import { ServiceError } from '../core/result.js';
13
14
  import { WorkspaceScanner } from '../core/scanner.js';
14
15
  import { applyReplacements, decodeSource, encodeSource, parseClausewitz, parseLocalisation, SOURCE_MAX_BYTES, sourcePartialLimitDiagnostics, } from '../core/source/index.js';
@@ -22,7 +23,7 @@ import { projectGuiGraphForArtifact } from './inspection-artifact.js';
22
23
  import { buildGuiScene } from './layout.js';
23
24
  import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
24
25
  import { compareGuiImages, galleryDimensions, renderGallerySvg, renderGuiScene, } from './renderer.js';
25
- import { generateGuiPreviewScenarios, resolveScenarioScriptedLocalisation, } from './scenario-generator.js';
26
+ import { generatedCountryTags, generateGuiPreviewScenarios, resolveScenarioScriptedLocalisation, } from './scenario-generator.js';
26
27
  import { parseGeneratedScenarioOptions, parsePreviewScenario } from './scenario.js';
27
28
  import { buildGuiSourceGraph } from './source-graph.js';
28
29
  import { assertGuiSourcePatchesSafe } from './source-patch.js';
@@ -242,13 +243,17 @@ function referenceVariants(value) {
242
243
  return candidates;
243
244
  }
244
245
  const FALLBACK_NAMES_PER_PATTERN = 100;
245
- function basenameFallbackPatterns(exactPaths, exactFiles) {
246
+ function basenameFallbackPatterns(exactPaths, exactFiles, assetRoots = ['gfx', 'interface', 'fonts']) {
246
247
  const resolved = new Set(exactFiles
247
248
  .filter(({ shadowedBy }) => shadowedBy === undefined)
248
249
  .map(({ relativePath }) => relativePath.replaceAll('\\', '/').toLowerCase()));
250
+ const stem = (value) => value.toLowerCase().replace(/\.(?:png|bmp|tga|dds|svg|fnt|ttf|otf|woff2?)$/u, '');
251
+ const resolvedStems = new Set([...resolved].map(stem));
249
252
  const names = [
250
253
  ...new Set(exactPaths
251
- .filter((candidate) => !/[*?[\]{}!]/u.test(candidate) && !resolved.has(candidate.toLowerCase()))
254
+ .filter((candidate) => !/[*?[\]{}!]/u.test(candidate) &&
255
+ !resolved.has(candidate.toLowerCase()) &&
256
+ !resolvedStems.has(stem(candidate)))
252
257
  .map((candidate) => path.posix.basename(candidate))),
253
258
  ].sort((left, right) => compareCodeUnits(left, right));
254
259
  // One brace group searches many names in a single bounded wildcard pattern, so a window
@@ -261,7 +266,9 @@ function basenameFallbackPatterns(exactPaths, exactFiles) {
261
266
  const chunk = grouped.slice(start, start + FALLBACK_NAMES_PER_PATTERN).map(escapeFileGlob);
262
267
  patterns.push(chunk.length === 1 ? `**/${chunk[0]}` : `**/{${chunk.join(',')}}`);
263
268
  }
264
- return patterns.sort((left, right) => compareCodeUnits(left, right));
269
+ return [...new Set(assetRoots)]
270
+ .flatMap((root) => patterns.map((pattern) => `${escapeFileGlob(normalizeConfiguredRoot(root))}/${pattern}`))
271
+ .sort(compareCodeUnits);
265
272
  }
266
273
  function selectedElements(graph, windowName, additionalTemplateNames = []) {
267
274
  const byId = new Map(graph.elements.map((element) => [element.id, element]));
@@ -549,10 +556,14 @@ export function scenarioCountryFlagPatterns(scenarios) {
549
556
  for (const values of [
550
557
  scenario.values,
551
558
  scenario.scriptedGui,
559
+ scenario.country ?? {},
552
560
  ...Object.values(scenario.lists).flat(),
553
561
  ]) {
554
562
  for (const [key, value] of Object.entries(values)) {
555
- if (key.endsWith('.countryTag') &&
563
+ if ((key.endsWith('.countryTag') ||
564
+ key.endsWith('.tag') ||
565
+ /(?:^|\.)GetFlag$/u.test(key) ||
566
+ ['countryTag', 'GetTag', 'tag'].includes(key)) &&
556
567
  typeof value === 'string' &&
557
568
  /^[A-Za-z0-9_]{2,64}$/u.test(value))
558
569
  tags.add(value.toUpperCase());
@@ -573,8 +584,14 @@ export function scenarioCountryFlagPatterns(scenarios) {
573
584
  function scenarioCountryHistoryPatterns(scenarios) {
574
585
  return scenarios.some(scenarioNeedsCountryHistory) ? ['history/countries/*.txt'] : [];
575
586
  }
576
- function scenarioCountryAssetPatterns(scenarios) {
577
- return [...scenarioCountryFlagPatterns(scenarios), ...scenarioCountryHistoryPatterns(scenarios)];
587
+ function scenarioCountryAssetPatterns(scenarios, generated = false) {
588
+ const flags = generated
589
+ ? [
590
+ ...scenarios,
591
+ ...generatedCountryTags.map((tag) => parsePreviewScenario({ id: `generated-${tag}`, country: { tag } })),
592
+ ]
593
+ : scenarios;
594
+ return [...scenarioCountryFlagPatterns(flags), ...scenarioCountryHistoryPatterns(scenarios)];
578
595
  }
579
596
  function bmFontPagePatterns(files) {
580
597
  const references = files.flatMap((file) => {
@@ -972,13 +989,9 @@ export class ScriptedGuiStudio {
972
989
  return cached;
973
990
  }
974
991
  const retainedFiles = [...files];
975
- // The previous graph of this scope still indexes every unchanged file.
976
- const previous = [...this.#graphCache].find(([existing]) => existing.startsWith(`${scope}:`))?.[1].index;
977
- const index = this.engine.indexFiles(retainedFiles, previous);
978
992
  const scanned = {
979
993
  files: retainedFiles,
980
- graph: buildGuiSourceGraph(retainedFiles, index, additionalLocalisationKeys),
981
- index,
994
+ graph: buildGuiSourceGraph(retainedFiles, undefined, additionalLocalisationKeys),
982
995
  };
983
996
  // One graph per scope: a preview builds layout, game-layout and definition graphs, and a
984
997
  // single shared slot made each repeat call rebuild all of them.
@@ -1003,50 +1016,75 @@ export class ScriptedGuiStudio {
1003
1016
  signal?.throwIfAborted();
1004
1017
  const workspace = this.resolver.get(workspaceId, principal);
1005
1018
  const [snapshot, localisationFiles] = await Promise.all([
1006
- this.engine.scan(workspaceId, { patterns: guiDefinitionPatterns(workspace) }, principal, signal),
1019
+ this.scanner.scan(workspace, {
1020
+ patterns: guiDefinitionPatterns(workspace),
1021
+ ...(signal === undefined ? {} : { signal }),
1022
+ }),
1007
1023
  this.scanner.scan(workspace, {
1008
1024
  patterns: guiLocalisationPatterns(workspace, languages),
1009
1025
  ...(signal === undefined ? {} : { signal }),
1010
1026
  }),
1011
1027
  ]);
1012
1028
  signal?.throwIfAborted();
1013
- const files = mergeScannedFiles(snapshot.files, localisationFiles);
1029
+ const files = mergeScannedFiles(snapshot, localisationFiles);
1014
1030
  return this.graphForFiles(files, `workspace:${workspaceId}`);
1015
1031
  }
1016
- async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = [], additionalLocalisationKeys = [], includeImmediateTooltip = false) {
1032
+ async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = [], additionalLocalisationKeys = [], includeImmediateTooltip = false, progress) {
1033
+ const stage = (phase) => renderStage({
1034
+ ...(signal === undefined ? {} : { signal }),
1035
+ ...(progress === undefined ? {} : { progress }),
1036
+ }, phase);
1037
+ await stage('Scanning GUI layouts');
1017
1038
  const workspace = this.resolver.get(workspaceId, principal);
1018
1039
  const layoutPatterns = guiLayoutPatterns(workspace);
1019
- const overlayLayouts = await this.engine.scan(workspaceId, {
1040
+ const overlayLayouts = await this.scanner.scan(workspace, {
1020
1041
  patterns: layoutPatterns,
1021
1042
  rootKinds: ['mod', 'dependency', 'fixture'],
1022
- }, principal, signal);
1023
- let layoutFiles = overlayLayouts.files;
1043
+ ...(signal === undefined ? {} : { signal }),
1044
+ });
1045
+ let layoutFiles = overlayLayouts;
1024
1046
  let layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
1025
1047
  const selectedLayoutElements = selectedElements(layoutGraph, windowName, additionalTemplateNames);
1026
1048
  const requiredNames = new Set([windowName, ...additionalTemplateNames]);
1027
1049
  for (const element of selectedLayoutElements)
1028
1050
  collectNamedAttributes(element.attributes, new Set(['verticalScrollbar', 'horizontalScrollbar', 'scrollbarType', 'scrollbartype']), requiredNames);
1029
- const availableNames = new Set(layoutGraph.elements.map(({ name }) => name));
1051
+ // Scrollbar and row references name public templates, not private controls with the same name.
1052
+ const availableNames = new Set(layoutGraph.elements.filter(({ parentId }) => parentId === undefined).map(({ name }) => name));
1053
+ if (layoutGraph.elements.some(({ name }) => name === windowName))
1054
+ availableNames.add(windowName);
1030
1055
  if ([...requiredNames].some((name) => !availableNames.has(name))) {
1031
- const gameLayouts = await this.engine.scan(workspaceId, { patterns: layoutPatterns, rootKinds: ['game', 'dlc'] }, principal, signal);
1032
- const gameGraph = this.graphForFiles(gameLayouts.files, `game-layout:${workspaceId}`).graph;
1056
+ const gameLayouts = await this.scanner.scan(workspace, {
1057
+ patterns: layoutPatterns,
1058
+ rootKinds: ['game', 'dlc'],
1059
+ ...(signal === undefined ? {} : { signal }),
1060
+ });
1061
+ const gameGraph = this.graphForFiles(gameLayouts, `game-layout:${workspaceId}`).graph;
1033
1062
  const selectedPaths = new Set(selectedElements(gameGraph, windowName, [...requiredNames]).map(({ sourcePath }) => sourcePath));
1034
- layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.files.filter(({ displayPath }) => selectedPaths.has(displayPath)));
1063
+ layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.filter(({ displayPath }) => selectedPaths.has(displayPath)));
1035
1064
  layoutGraph = this.graphForFiles(layoutFiles, `merged-layout:${workspaceId}`).graph;
1036
1065
  }
1037
1066
  if (!layoutGraph.elements.some(({ name }) => name === windowName))
1038
1067
  assertTargetWindowAvailable(layoutGraph, windowName);
1068
+ await stage('Scanning GUI definitions and localisation');
1039
1069
  const [linkedDefinitions, localisation] = await Promise.all([
1040
- this.engine.scan(workspaceId, { patterns: guiLinkedDefinitionPatterns(workspace) }, principal, signal),
1041
- this.engine.scan(workspaceId, { patterns: guiLocalisationPatterns(workspace, languages) }, principal, signal),
1070
+ this.scanner.scan(workspace, {
1071
+ patterns: guiLinkedDefinitionPatterns(workspace),
1072
+ ...(signal === undefined ? {} : { signal }),
1073
+ }),
1074
+ this.scanner.scan(workspace, {
1075
+ patterns: guiLocalisationPatterns(workspace, languages),
1076
+ ...(signal === undefined ? {} : { signal }),
1077
+ }),
1042
1078
  ]);
1043
- const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions.files, localisation.files);
1079
+ const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions, localisation);
1080
+ await stage('Building GUI source graph');
1044
1081
  const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`, additionalLocalisationKeys);
1045
1082
  assertTargetWindowAvailable(definitions.graph, windowName);
1046
1083
  const referencedPatterns = [
1047
- ...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames, includeImmediateTooltip),
1084
+ ...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames, includeImmediateTooltip).filter((pattern) => !pattern.startsWith('gfx/flags/')),
1048
1085
  ...additionalAssetPatterns,
1049
1086
  ];
1087
+ await stage('Loading selected GUI assets');
1050
1088
  const exactReferenced = referencedPatterns.length === 0
1051
1089
  ? []
1052
1090
  : await this.scanner.scan(workspace, {
@@ -1054,7 +1092,11 @@ export class ScriptedGuiStudio {
1054
1092
  ...(signal === undefined ? {} : { signal }),
1055
1093
  });
1056
1094
  signal?.throwIfAborted();
1057
- const fallbackPatterns = basenameFallbackPatterns(referencedPatterns, exactReferenced);
1095
+ const fallbackPatterns = basenameFallbackPatterns(referencedPatterns, exactReferenced, [
1096
+ ...workspace.registration.roots.gfx,
1097
+ ...workspace.registration.roots.interface,
1098
+ 'fonts',
1099
+ ]);
1058
1100
  const fallbackReferenced = fallbackPatterns.length === 0
1059
1101
  ? []
1060
1102
  : await this.scanner.scan(workspace, {
@@ -1070,7 +1112,11 @@ export class ScriptedGuiStudio {
1070
1112
  ...(signal === undefined ? {} : { signal }),
1071
1113
  });
1072
1114
  signal?.throwIfAborted();
1073
- const fontPageFallbackPatterns = basenameFallbackPatterns(fontPagePatterns, exactFontPages);
1115
+ const fontPageFallbackPatterns = basenameFallbackPatterns(fontPagePatterns, exactFontPages, [
1116
+ ...workspace.registration.roots.gfx,
1117
+ ...workspace.registration.roots.interface,
1118
+ 'fonts',
1119
+ ]);
1074
1120
  const fallbackFontPages = fontPageFallbackPatterns.length === 0
1075
1121
  ? []
1076
1122
  : await this.scanner.scan(workspace, {
@@ -1079,7 +1125,26 @@ export class ScriptedGuiStudio {
1079
1125
  });
1080
1126
  const fontPages = mergeScannedFiles(exactFontPages, fallbackFontPages);
1081
1127
  signal?.throwIfAborted();
1082
- const files = mergeScannedFiles(definitions.files, referenced, fontPages);
1128
+ const windowElements = selectedElements(definitions.graph, windowName, additionalTemplateNames);
1129
+ const selectedPaths = new Set(windowElements.map(({ sourcePath }) => sourcePath));
1130
+ const namedSprites = new Set(additionalSpriteNames);
1131
+ const namedFonts = new Set();
1132
+ for (const element of windowElements) {
1133
+ collectNamedAttributes(element.attributes, new Set(['spriteType', 'quadTextureSprite']), namedSprites);
1134
+ collectNamedAttributes(element.attributes, new Set(['font', 'buttonFont']), namedFonts);
1135
+ }
1136
+ const assetPaths = new Set(referencedPatterns.map((value) => value.toLowerCase()));
1137
+ for (const sprite of definitions.graph.sprites)
1138
+ if (namedSprites.has(sprite.name) ||
1139
+ [sprite.texturePath, sprite.texturePath2, sprite.effectFile].some((value) => value !== undefined &&
1140
+ referenceVariants(value).some((variant) => assetPaths.has(variant.toLowerCase()))))
1141
+ selectedPaths.add(sprite.sourcePath);
1142
+ for (const font of definitions.graph.fonts)
1143
+ if (namedFonts.has(font.name) ||
1144
+ font.assetPaths.some((value) => referenceVariants(value).some((variant) => assetPaths.has(variant.toLowerCase()))))
1145
+ selectedPaths.add(font.sourcePath);
1146
+ const files = mergeScannedFiles(definitions.files.filter((file) => !/\.(?:gui|gfx)$/iu.test(file.relativePath) || selectedPaths.has(file.displayPath)), referenced, fontPages);
1147
+ await stage('Resolving selected GUI assets');
1083
1148
  return this.graphForFiles(files, `window:${workspaceId}:${windowName}`, additionalLocalisationKeys);
1084
1149
  }
1085
1150
  async lint(input) {
@@ -1089,7 +1154,7 @@ export class ScriptedGuiStudio {
1089
1154
  const generatedOptions = input.generatedScenarios === undefined
1090
1155
  ? undefined
1091
1156
  : parseGeneratedScenarioOptions(input.generatedScenarios);
1092
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryAssetPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined));
1157
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryAssetPatterns([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.enabled === true), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined), input.progress);
1093
1158
  let builtDefinitions;
1094
1159
  const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
1095
1160
  const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
@@ -1108,6 +1173,7 @@ export class ScriptedGuiStudio {
1108
1173
  assertUniqueScenarioIds([scenario, ...relatedScenarios]);
1109
1174
  const budget = new RenderBudget();
1110
1175
  const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
1176
+ await renderStage(input, 'Laying out GUI and decoding visible assets');
1111
1177
  const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
1112
1178
  const relatedScenes = [];
1113
1179
  for (const related of relatedScenarios) {
@@ -1179,7 +1245,7 @@ export class ScriptedGuiStudio {
1179
1245
  placeholderScenario,
1180
1246
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1181
1247
  ...explicitRelatedScenarios,
1182
- ]), scenarioLocalisationKeys([
1248
+ ], generatedOptions?.enabled === true), scenarioLocalisationKeys([
1183
1249
  placeholderScenario,
1184
1250
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1185
1251
  ...explicitRelatedScenarios,
@@ -1187,7 +1253,7 @@ export class ScriptedGuiStudio {
1187
1253
  placeholderScenario,
1188
1254
  ...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
1189
1255
  ...explicitRelatedScenarios,
1190
- ].some(({ tooltip }) => tooltip !== undefined));
1256
+ ].some(({ tooltip }) => tooltip !== undefined), input.progress);
1191
1257
  let builtDefinitions;
1192
1258
  const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
1193
1259
  const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
@@ -1207,7 +1273,9 @@ export class ScriptedGuiStudio {
1207
1273
  const baselineScenario = requestedBaselineScenario ??
1208
1274
  parsePreviewScenario({ ...scenario, id: `${scenario.id}-comparison`, state: 'normal' });
1209
1275
  const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
1276
+ await renderStage(input, 'Laying out GUI and decoding visible assets');
1210
1277
  const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
1278
+ await renderStage(input, 'Rasterizing GUI views');
1211
1279
  const render = await renderGuiScene(scene, undefined, input.signal, budget);
1212
1280
  const fullRenderCache = new Map([
1213
1281
  [visualSceneKey(scene), fullImage(render)],
@@ -1319,6 +1387,7 @@ export class ScriptedGuiStudio {
1319
1387
  validation.diagnostics.push(...stateValidation.diagnostics, ...resolutionValidation.diagnostics);
1320
1388
  validation.checks.push(...stateValidation.checks, ...resolutionValidation.checks);
1321
1389
  const completeValidation = withGraphDiagnostics(validationGraph, validation);
1390
+ await renderStage(input, 'Writing GUI galleries and evidence');
1322
1391
  const scenarioGallerySvg = renderGallerySvg(`${input.windowName} scripted scenario matrix`, scenarioItems);
1323
1392
  const stateGallerySvg = renderGallerySvg(`${input.windowName} state matrix`, stateItems);
1324
1393
  const resolutionGallerySvg = renderGallerySvg(`${input.windowName} resolution and UI-scale matrix`, resolutionItems);
@@ -120,11 +120,14 @@ export async function inspectGui(engine, studio, input, context) {
120
120
  const linted = await studio.lint({
121
121
  workspaceId: context.workspaceId,
122
122
  windowName: input.windowName,
123
+ progress: (phase) => context.progress(1, 3, phase),
123
124
  scenario: input.scenario,
124
125
  ...(input.relatedScenarios === undefined
125
126
  ? {}
126
127
  : { relatedScenarios: input.relatedScenarios }),
127
- generatedScenarios: input.generatedScenarios ?? {},
128
+ ...(input.generatedScenarios === undefined
129
+ ? {}
130
+ : { generatedScenarios: input.generatedScenarios }),
128
131
  ...(context.principal === undefined ? {} : { principal: context.principal }),
129
132
  ...(context.signal === undefined ? {} : { signal: context.signal }),
130
133
  });
@@ -217,6 +220,7 @@ export async function inspectGui(engine, studio, input, context) {
217
220
  export async function renderGui(studio, input, context) {
218
221
  await context.progress(0, 4, 'Building GUI source graph');
219
222
  const rendered = await studio.renderAndStore({
223
+ progress: (phase) => context.progress(1, 4, phase),
220
224
  workspaceId: context.workspaceId,
221
225
  windowName: input.windowName,
222
226
  scenario: input.scenario,
@@ -231,7 +235,9 @@ export async function renderGui(studio, input, context) {
231
235
  })),
232
236
  }),
233
237
  ...(input.relatedScenarios === undefined ? {} : { relatedScenarios: input.relatedScenarios }),
234
- generatedScenarios: input.generatedScenarios ?? {},
238
+ ...(input.generatedScenarios === undefined
239
+ ? {}
240
+ : { generatedScenarios: input.generatedScenarios }),
235
241
  ...(input.comparisonScenario === undefined
236
242
  ? {}
237
243
  : { comparisonScenario: input.comparisonScenario }),
@@ -17,7 +17,7 @@ import { referenceTools } from '../tools/reference.js';
17
17
  import { isReferenceTool, ReferenceToolService } from '../../reference/operations.js';
18
18
  import { ModernTaskAdapter, TASKS_EXTENSION } from './modern-tasks.js';
19
19
  import { SERVER_INSTRUCTIONS } from './instructions.js';
20
- import { modernProgressReporter, withProgressHeartbeat } from './progress.js';
20
+ import { jobProgressReporter, modernProgressReporter, withProgressHeartbeat } from './progress.js';
21
21
  import { taskToolCatalog } from './task-tool-catalog.js';
22
22
  const rootsResponseSchema = z
23
23
  .object({
@@ -187,6 +187,7 @@ export function createModernOperationServer(engine, context = {}, tasks = new Op
187
187
  const progress = modernProgressReporter(extra);
188
188
  const result = await withProgressHeartbeat(async () => {
189
189
  const completed = await adapter.call({ name: definition.name, arguments: arguments_ }, capabilities, operationContext, {
190
+ progress: jobProgressReporter(progress),
190
191
  requestId: extra.mcpReq.id,
191
192
  signal: extra.mcpReq.signal,
192
193
  ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }),
@@ -2,7 +2,7 @@ import { CallToolResultSchema, } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { jobTaskTiming } from '../../core/job-store.js';
3
3
  import { OperationTaskService, operationTaskCallSchema, operationTaskId, operationTaskMessage, operationTaskStatus, } from '../../core/operation-tasks.js';
4
4
  import { ServiceError } from '../../core/result.js';
5
- import { currentTaskRequestSignal } from './task-request-context.js';
5
+ import { currentTaskRequestSignal, currentTaskRequestProgress } from './task-request-context.js';
6
6
  function legacyTask(record) {
7
7
  if (record.request.protocolTask === undefined)
8
8
  throw new ServiceError('TASK_NOT_FOUND', 'The requested protocol task does not exist');
@@ -44,6 +44,8 @@ export class PersistentJobTaskStore {
44
44
  }
45
45
  async getTask(value, _sessionId) {
46
46
  const record = await this.tasks.get(value, this.context());
47
+ if (record?.progress !== undefined)
48
+ await currentTaskRequestProgress()?.(record.progress.completed, record.progress.total ?? 1, record.progress.message);
47
49
  return record === null ? null : legacyTask(record);
48
50
  }
49
51
  async storeTaskResult(value, status, result, _sessionId) {
@@ -112,6 +112,8 @@ export class ModernTaskAdapter {
112
112
  options.signal?.throwIfAborted();
113
113
  await delay(100, undefined, { signal: options.signal });
114
114
  latest = await this.known(taskId, context);
115
+ if (latest.progress !== undefined)
116
+ await options.progress?.(latest.progress.completed, latest.progress.total ?? 1, latest.progress.message);
115
117
  }
116
118
  return modernToolResult(latest, await this.tasks.result(operationTaskId(record), context));
117
119
  }
@@ -101,3 +101,15 @@ export async function withProgressHeartbeat(operation, reporter, message, interv
101
101
  heartbeatOwners.delete(reporter);
102
102
  }
103
103
  }
104
+ /** Reserve the outer request's final two steps for retrieval/completion while forwarding worker phases. */
105
+ export function jobProgressReporter(reporter) {
106
+ let previous;
107
+ return async (completed, total, message) => {
108
+ const identity = JSON.stringify([completed, total, message]);
109
+ if (identity === previous)
110
+ return;
111
+ previous = identity;
112
+ await reporter.report(Math.min(1, completed / Math.max(1, total)), 3, message);
113
+ await reporter.pulse(message);
114
+ };
115
+ }
@@ -5,7 +5,7 @@ import { referenceTools } from '../tools/reference.js';
5
5
  import { jobControlTools } from '../tools/job.js';
6
6
  import { invalidArgumentsResult } from './argument-errors.js';
7
7
  import { taskToolCatalog } from './task-tool-catalog.js';
8
- import { progressReporter, withProgressHeartbeat } from './progress.js';
8
+ import { jobProgressReporter, progressReporter, withProgressHeartbeat } from './progress.js';
9
9
  import { withTaskRequestSignal } from './task-request-context.js';
10
10
  import { slimToolList } from './tool-listing.js';
11
11
  // Durable task compatibility and cancellation are control traffic. They must
@@ -42,7 +42,7 @@ export function installRequestLifecycle(server, engine) {
42
42
  return handler(request, extra);
43
43
  if (taskCall.success && backgroundTools.has(taskCall.data.params.name)) {
44
44
  return withProgressHeartbeat(async () => {
45
- const result = await withTaskRequestSignal(extra.signal, () => handler(request, extra));
45
+ const result = await withTaskRequestSignal(extra.signal, () => handler(request, extra), jobProgressReporter(progress));
46
46
  if (taskCall.data.params.task === undefined) {
47
47
  await progress.report(2, 3, 'Retrieving persistent operation result');
48
48
  await progress.report(3, 3, 'Persistent operation complete');
@@ -1,9 +1,12 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  const taskRequestContext = new AsyncLocalStorage();
3
3
  /** Bind transport cancellation to foreground-compatible task creation only. */
4
- export function withTaskRequestSignal(signal, action) {
5
- return taskRequestContext.run({ signal }, action);
4
+ export function withTaskRequestSignal(signal, action, progress) {
5
+ return taskRequestContext.run({ signal, ...(progress === undefined ? {} : { progress }) }, action);
6
6
  }
7
7
  export function currentTaskRequestSignal() {
8
8
  return taskRequestContext.getStore()?.signal;
9
9
  }
10
+ export function currentTaskRequestProgress() {
11
+ return taskRequestContext.getStore()?.progress;
12
+ }
@@ -22,6 +22,7 @@ export function registerTechnologyJobs(operations, engine) {
22
22
  workspaceId: context.workspaceId,
23
23
  ...(context.principal === undefined ? {} : { principal: context.principal }),
24
24
  signal: context.signal,
25
+ progress: (phase) => context.progress({ completed: 1, total: 3, message: phase }),
25
26
  });
26
27
  await context.progress({
27
28
  completed: 3,