hoi4-agent-tools 2.8.1 → 2.8.3

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/hoi4_agent_tools/core/artifacts.js +126 -4
  3. package/dist/hoi4_agent_tools/core/index.js +3 -1
  4. package/dist/hoi4_agent_tools/core/scanner.js +43 -2
  5. package/dist/hoi4_agent_tools/gui/assets.js +63 -14
  6. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +85 -14
  7. package/dist/hoi4_agent_tools/gui/layout.js +234 -27
  8. package/dist/hoi4_agent_tools/gui/scenario.js +1 -0
  9. package/dist/hoi4_agent_tools/gui/source-graph.js +116 -7
  10. package/dist/hoi4_agent_tools/gui/studio.js +166 -34
  11. package/dist/hoi4_agent_tools/gui/validators.js +47 -27
  12. package/dist/hoi4_agent_tools/mcp/server/progress.js +3 -0
  13. package/dist/hoi4_agent_tools/version.js +1 -1
  14. package/docs/gui.md +11 -1
  15. package/package.json +1 -1
  16. package/schemas/configuration.schema.json +1 -1
  17. package/schemas/continuous-focus-palette.schema.json +1 -1
  18. package/schemas/custom-weighted-pool.schema.json +1 -1
  19. package/schemas/event-feature-manifest.schema.json +1 -1
  20. package/schemas/focus-plan.schema.json +1 -1
  21. package/schemas/focus-planning-sidecar.schema.json +1 -1
  22. package/schemas/gui-animation-source.schema.json +1 -1
  23. package/schemas/gui-helper.schema.json +1 -1
  24. package/schemas/gui-scenario.schema.json +29 -22
  25. package/schemas/map-operation.schema.json +1 -1
  26. package/schemas/operation-result.schema.json +1 -1
  27. package/schemas/probability-analysis-result.schema.json +1 -1
  28. package/schemas/probability-compare-input.schema.json +1 -1
  29. package/schemas/probability-evaluate-input.schema.json +1 -1
  30. package/schemas/probability-inspect-input.schema.json +1 -1
  31. package/schemas/probability-render-input.schema.json +1 -1
  32. package/schemas/probability-scenario-set.schema.json +1 -1
  33. package/schemas/probability-sequence-input.schema.json +1 -1
  34. package/schemas/probability-simulate-input.schema.json +1 -1
  35. package/schemas/probability-sweep-input.schema.json +1 -1
  36. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 2.8.3 - 2026-08-27
8
+
9
+ - Centered native button labels when HOI4 GUI source uses `text =` instead of `buttonText =`, matching the in-game horizontal and vertical alignment inside button frames.
10
+ - Added one scenario `values` map for concrete runtime variables, dynamic text, meter fills, sprites, frames, positions, visibility, and enabled states so agents can render complete value-driven GUI versions without assembling several low-level override maps.
11
+
12
+ ## 2.8.2 - 2026-08-27
13
+
14
+ - Composed top-level scripted-GUI child windows through `parent_scripted_gui`, instantiated external dynamic-list entry containers, and applied row-scoped image, frame, position, visibility, and enabled values.
15
+ - Applied constant scripted visibility and click-enabled triggers, narrowed target scans and diagnostics to linked GUI surfaces, and accepted documented installed-game scripted-GUI contexts without hard false failures.
16
+ - Rendered language-correct HOI4 bitmap fonts and recovered installed-game first-page atlases whose actual filename omits the declared page suffix.
17
+ - Resolved alternate installed raster extensions, stopped shared sprite textures from producing false symbol collisions, cached unchanged scanned bytes, refreshed active artifact recency, and reclaimed stale temporary artifact debris.
18
+ - Made concurrent artifact accounting tolerate entries that finish publishing between enumeration and inspection, and emit the first long-operation progress heartbeat immediately.
19
+
7
20
  ## 2.8.1 - 2026-08-27
8
21
 
9
22
  - Added periodic MCP progress heartbeats for long GUI inspections, renders, and rewrites, with repeated progress notifications that preserve monotonic work values and still honor cancellation.
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { link, lstat, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises';
2
+ import { link, lstat, mkdir, open, readFile, readdir, rmdir, stat, unlink } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { z } from 'zod/v4';
5
5
  import { canonicalJson, compareCodeUnits, sha256Bytes } from './canonical.js';
@@ -157,6 +157,7 @@ const artifactReadBufferBytes = 1_048_576;
157
157
  const artifactPageMaxEntries = 100;
158
158
  const artifactManifestMaxBytes = 1_048_576;
159
159
  const artifactLogicalBatchMaxBytes = 536_870_912;
160
+ const artifactDebrisMinimumAgeMs = 15 * 60 * 1_000;
160
161
  const artifactHashYieldBytes = 16_777_216;
161
162
  const artifactPreflightYieldEntries = 256;
162
163
  const interruptedManifestMinimumAgeMs = 1_000;
@@ -306,6 +307,27 @@ async function withArtifactQueue(root, action, signal) {
306
307
  async function artifactUsage(root, signal) {
307
308
  let bytes = 0;
308
309
  let entries = 0;
310
+ const canonicalRoot = await containedGeneratedPath(root);
311
+ const resolveListedPath = async (directory, name) => {
312
+ const lexicalPath = path.join(directory, name);
313
+ try {
314
+ return await containedGeneratedPath(canonicalRoot, path.relative(canonicalRoot, lexicalPath));
315
+ }
316
+ catch (error) {
317
+ if (error.code === 'ENOENT')
318
+ return undefined;
319
+ if (error instanceof ServiceError && error.code === 'PATH_GENERATED_ROOT_ESCAPE') {
320
+ try {
321
+ await lstat(lexicalPath);
322
+ }
323
+ catch (metadataError) {
324
+ if (metadataError.code === 'ENOENT')
325
+ return undefined;
326
+ }
327
+ }
328
+ throw error;
329
+ }
330
+ };
309
331
  const walk = async (directory) => {
310
332
  signal?.throwIfAborted();
311
333
  let children;
@@ -322,9 +344,24 @@ async function artifactUsage(root, signal) {
322
344
  if (child.isSymbolicLink()) {
323
345
  throw new ServiceError('ARTIFACT_STORAGE_UNSAFE', 'Artifact storage contains a symbolic link or junction');
324
346
  }
325
- if (child.isFile() && child.name.startsWith(artifactTemporaryPrefix))
347
+ if (child.isFile() && child.name.startsWith(artifactTemporaryPrefix)) {
348
+ const temporary = await resolveListedPath(directory, child.name);
349
+ if (temporary === undefined)
350
+ continue;
351
+ try {
352
+ const metadata = await lstat(temporary);
353
+ if (Date.now() - metadata.mtimeMs >= artifactDebrisMinimumAgeMs)
354
+ await unlink(temporary);
355
+ }
356
+ catch (error) {
357
+ if (error.code !== 'ENOENT')
358
+ throw error;
359
+ }
360
+ continue;
361
+ }
362
+ const candidate = await resolveListedPath(directory, child.name);
363
+ if (candidate === undefined)
326
364
  continue;
327
- const candidate = await containedGeneratedPath(root, path.relative(root, path.join(directory, child.name)));
328
365
  if (child.isDirectory())
329
366
  await walk(candidate);
330
367
  else if (child.isFile()) {
@@ -345,8 +382,17 @@ async function artifactUsage(root, signal) {
345
382
  }
346
383
  }
347
384
  }
385
+ if (directory !== canonicalRoot) {
386
+ try {
387
+ await rmdir(directory);
388
+ }
389
+ catch (error) {
390
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
391
+ throw error;
392
+ }
393
+ }
348
394
  };
349
- await walk(await containedGeneratedPath(root));
395
+ await walk(canonicalRoot);
350
396
  return { bytes, entries };
351
397
  }
352
398
  async function readArtifactManifest(filePath, signal) {
@@ -388,6 +434,25 @@ async function readArtifactManifest(filePath, signal) {
388
434
  throw new ServiceError('ARTIFACT_MANIFEST_INVALID', 'Artifact provenance manifest is invalid');
389
435
  }
390
436
  }
437
+ async function refreshArtifactManifest(filePath) {
438
+ let handle;
439
+ try {
440
+ handle = await open(filePath, 'r+');
441
+ const metadata = await handle.stat();
442
+ if (!metadata.isFile())
443
+ throw new ServiceError('ARTIFACT_MANIFEST_INVALID', 'Artifact provenance manifest is invalid');
444
+ const now = new Date();
445
+ await handle.utimes(now, now);
446
+ }
447
+ catch (error) {
448
+ if (error instanceof ServiceError)
449
+ throw error;
450
+ throw new ServiceError('ARTIFACT_NOT_FOUND', 'Artifact provenance manifest is unavailable');
451
+ }
452
+ finally {
453
+ await handle?.close().catch(() => undefined);
454
+ }
455
+ }
391
456
  async function readBoundedManifestBytes(filePath, signal) {
392
457
  signal?.throwIfAborted();
393
458
  let metadata;
@@ -672,6 +737,58 @@ export class ArtifactStore {
672
737
  }
673
738
  };
674
739
  await walk(await containedGeneratedPath(workspace.artifactRoot));
740
+ const referencedTargets = new Set([
741
+ ...retained.map(({ targetPath }) => targetPath),
742
+ ...protectedTargets,
743
+ ]);
744
+ const reclaimDebris = async (directory) => {
745
+ signal?.throwIfAborted();
746
+ let children;
747
+ try {
748
+ children = await readdir(directory, { withFileTypes: true });
749
+ }
750
+ catch (error) {
751
+ if (error.code === 'ENOENT')
752
+ return;
753
+ throw error;
754
+ }
755
+ for (const child of children) {
756
+ signal?.throwIfAborted();
757
+ if (child.isSymbolicLink())
758
+ throw new ServiceError('ARTIFACT_STORAGE_UNSAFE', 'Artifact storage contains a symbolic link or junction');
759
+ const candidate = await containedGeneratedPath(workspace.artifactRoot, path.relative(workspace.artifactRoot, path.join(directory, child.name)));
760
+ if (child.isDirectory()) {
761
+ await reclaimDebris(candidate);
762
+ continue;
763
+ }
764
+ if (!child.isFile() || child.name.endsWith('.manifest.json'))
765
+ continue;
766
+ const temporary = child.name.startsWith(artifactTemporaryPrefix);
767
+ if (!temporary && referencedTargets.has(candidate))
768
+ continue;
769
+ let metadata;
770
+ try {
771
+ metadata = await lstat(candidate);
772
+ }
773
+ catch (error) {
774
+ if (error.code === 'ENOENT')
775
+ continue;
776
+ throw error;
777
+ }
778
+ if (Date.now() - metadata.mtimeMs < artifactDebrisMinimumAgeMs)
779
+ continue;
780
+ try {
781
+ await unlink(candidate);
782
+ if (!temporary)
783
+ usage.bytes = Math.max(0, usage.bytes - metadata.size);
784
+ }
785
+ catch (error) {
786
+ if (error.code !== 'ENOENT')
787
+ throw error;
788
+ }
789
+ }
790
+ };
791
+ await reclaimDebris(await containedGeneratedPath(workspace.artifactRoot));
675
792
  retained.sort((left, right) => left.modifiedAt - right.modifiedAt ||
676
793
  compareCodeUnits(left.manifestPath, right.manifestPath));
677
794
  const current = { ...usage };
@@ -1192,6 +1309,10 @@ export class ArtifactStore {
1192
1309
  }
1193
1310
  }
1194
1311
  signal?.throwIfAborted();
1312
+ for (const artifact of uniqueManifests.values()) {
1313
+ await refreshArtifactManifest(artifact.manifestPath);
1314
+ }
1315
+ signal?.throwIfAborted();
1195
1316
  // The caller's commit callback is the ownership boundary. Once entered,
1196
1317
  // it completes or fails under its own critical-phase rules; a late abort
1197
1318
  // cannot make the store remove evidence already committed elsewhere.
@@ -1245,6 +1366,7 @@ export class ArtifactStore {
1245
1366
  const verificationKey = `${workspace.workspaceIdentity}\0${workspace.ownerIdentity}\0${target}\0${sha256}`;
1246
1367
  const { bytes, totalSize, identity } = await readVerifiedArtifactRange(target, sha256, range, this.#verifiedContent.get(verificationKey), signal);
1247
1368
  this.#verifiedContent.set(verificationKey, identity);
1369
+ await refreshArtifactManifest(manifestPath);
1248
1370
  return { bytes, mimeType: manifest.mimeType, name, totalSize };
1249
1371
  }
1250
1372
  uri(workspaceId, sha256, provenanceHash, name) {
@@ -1299,7 +1299,9 @@ export class SymbolIndex {
1299
1299
  continue;
1300
1300
  const active = candidates[0];
1301
1301
  this.#active.set(key, active);
1302
- const additiveCategory = active.kind === 'decision_category' || active.kind === 'technology_category';
1302
+ const additiveCategory = active.kind === 'decision_category' ||
1303
+ active.kind === 'technology_category' ||
1304
+ active.kind === 'texture';
1303
1305
  for (const candidate of group) {
1304
1306
  candidate.overridden = additiveCategory ? candidate.sourceShadowed : candidate !== active;
1305
1307
  }
@@ -25,10 +25,33 @@ export class WorkspaceScanner {
25
25
  serverMaxFiles;
26
26
  serverMaxBytes;
27
27
  serverMaxFileBytes;
28
+ #sourceCache = new Map();
29
+ #sourceCacheBytes = 0;
30
+ #sourceCacheMaxBytes;
28
31
  constructor(serverMaxFiles = 20_000, serverMaxBytes = 134_217_728, serverMaxFileBytes = 67_108_864) {
29
32
  this.serverMaxFiles = serverMaxFiles;
30
33
  this.serverMaxBytes = serverMaxBytes;
31
34
  this.serverMaxFileBytes = serverMaxFileBytes;
35
+ this.#sourceCacheMaxBytes = Math.max(serverMaxFileBytes, Math.min(536_870_912, serverMaxBytes * 2));
36
+ }
37
+ cacheKey(absolutePath) {
38
+ const resolved = path.resolve(absolutePath);
39
+ return process.platform === 'win32' ? resolved.toLocaleLowerCase('en-US') : resolved;
40
+ }
41
+ retainSource(key, source) {
42
+ const previous = this.#sourceCache.get(key);
43
+ if (previous !== undefined)
44
+ this.#sourceCacheBytes -= previous.bytes.length;
45
+ this.#sourceCache.delete(key);
46
+ this.#sourceCache.set(key, source);
47
+ this.#sourceCacheBytes += source.bytes.length;
48
+ while (this.#sourceCacheBytes > this.#sourceCacheMaxBytes && this.#sourceCache.size > 1) {
49
+ const oldest = this.#sourceCache.entries().next().value;
50
+ if (oldest === undefined)
51
+ break;
52
+ this.#sourceCache.delete(oldest[0]);
53
+ this.#sourceCacheBytes -= oldest[1].bytes.length;
54
+ }
32
55
  }
33
56
  async scan(workspace, options) {
34
57
  options.signal?.throwIfAborted();
@@ -72,6 +95,7 @@ export class WorkspaceScanner {
72
95
  if (hiddenByReplacePath(workspace, root, relativePath))
73
96
  continue;
74
97
  const absolutePath = path.join(root.path, relativePath);
98
+ const cacheKey = this.cacheKey(absolutePath);
75
99
  const handle = await open(absolutePath, 'r');
76
100
  try {
77
101
  const metadata = await handle.stat();
@@ -81,7 +105,24 @@ export class WorkspaceScanner {
81
105
  if (metadata.size > remaining || metadata.size > this.serverMaxFileBytes) {
82
106
  throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit');
83
107
  }
84
- const bytes = await readBoundedFile(handle, Math.min(remaining, this.serverMaxFileBytes), options.signal);
108
+ const cached = this.#sourceCache.get(cacheKey);
109
+ const retained = cached?.size === metadata.size &&
110
+ cached.modifiedMs === metadata.mtimeMs &&
111
+ cached.changedMs === metadata.ctimeMs &&
112
+ cached.inode === metadata.ino
113
+ ? cached
114
+ : undefined;
115
+ const bytes = retained?.bytes ??
116
+ (await readBoundedFile(handle, Math.min(remaining, this.serverMaxFileBytes), options.signal));
117
+ const sha256 = retained?.sha256 ?? sha256Bytes(bytes);
118
+ this.retainSource(cacheKey, {
119
+ size: metadata.size,
120
+ modifiedMs: metadata.mtimeMs,
121
+ changedMs: metadata.ctimeMs,
122
+ inode: metadata.ino,
123
+ sha256,
124
+ bytes,
125
+ });
85
126
  totalBytes += bytes.length;
86
127
  result.push({
87
128
  absolutePath,
@@ -91,7 +132,7 @@ export class WorkspaceScanner {
91
132
  loadOrder: root.loadOrder,
92
133
  size: bytes.length,
93
134
  modifiedMs: metadata.mtimeMs,
94
- sha256: sha256Bytes(bytes),
135
+ sha256,
95
136
  bytes,
96
137
  });
97
138
  }
@@ -153,7 +153,7 @@ export class GuiAssetCatalog {
153
153
  glyphRasters = new Map();
154
154
  metrics = new Map();
155
155
  fontDefinitions = new Map();
156
- constructor(graph, scannedFiles, budget = new RenderBudget()) {
156
+ constructor(graph, scannedFiles, budget = new RenderBudget(), language = 'l_english') {
157
157
  this.graph = graph;
158
158
  this.budget = budget;
159
159
  for (const file of scannedFiles
@@ -166,14 +166,35 @@ export class GuiAssetCatalog {
166
166
  candidates.push(file);
167
167
  this.basenames.set(basename, candidates);
168
168
  }
169
- for (const definition of graph.fonts)
170
- this.fontDefinitions.set(definition.name.toLowerCase(), definition);
169
+ const normalizedLanguage = language.toLocaleLowerCase('en-US');
170
+ for (const definition of graph.fonts) {
171
+ const key = definition.name.toLocaleLowerCase('en-US');
172
+ const existing = this.fontDefinitions.get(key);
173
+ const definitionMatches = definition.languages.some((candidate) => candidate.toLocaleLowerCase('en-US') === normalizedLanguage);
174
+ const existingMatches = existing?.languages.some((candidate) => candidate.toLocaleLowerCase('en-US') === normalizedLanguage) ?? false;
175
+ if (existing === undefined ||
176
+ (definitionMatches && !existingMatches) ||
177
+ (definitionMatches === existingMatches && existing.override && !definition.override) ||
178
+ (definitionMatches === existingMatches &&
179
+ existing.languages.length > 0 &&
180
+ definition.languages.length === 0))
181
+ this.fontDefinitions.set(key, definition);
182
+ }
171
183
  }
172
184
  resolveFile(assetPath, relativeTo) {
173
185
  const normalized = normalizeAssetPath(assetPath);
174
- const normalizedCandidates = path.posix.extname(normalized).length === 0
186
+ const extension = path.posix.extname(normalized);
187
+ const rasterExtensions = ['.png', '.bmp', '.tga', '.dds', '.svg'];
188
+ const normalizedCandidates = extension.length === 0
175
189
  ? [normalized, `${normalized}.fnt`, `${normalized}.ttf`, `${normalized}.otf`]
176
- : [normalized];
190
+ : rasterExtensions.includes(extension)
191
+ ? [
192
+ normalized,
193
+ ...rasterExtensions
194
+ .filter((candidate) => candidate !== extension)
195
+ .map((candidate) => `${normalized.slice(0, -extension.length)}${candidate}`),
196
+ ]
197
+ : [normalized];
177
198
  for (const candidatePath of normalizedCandidates) {
178
199
  const direct = this.files.get(candidatePath);
179
200
  if (direct !== undefined)
@@ -399,13 +420,12 @@ export class GuiAssetCatalog {
399
420
  if (previous !== undefined)
400
421
  penX += (entry.bmfont.kerning.get(`${previous}:${codePoint}`) ?? 0) * scale;
401
422
  const pagePath = entry.bmfont.pages[metric.page];
402
- const raster = pagePath === undefined
423
+ const pageFile = pagePath === undefined ? undefined : this.resolveBmFontPage(entry, pagePath, metric.page);
424
+ const raster = pageFile === undefined
403
425
  ? undefined
404
- : await this.loadBmFontGlyph(entry, pagePath, metric, codePoint);
405
- if (raster !== undefined && pagePath !== undefined) {
406
- const page = this.resolveFile(pagePath, entry.sourceFile.relativePath);
407
- if (page !== undefined)
408
- pageHashes.add(page.sha256);
426
+ : await this.loadBmFontGlyph(entry, pageFile, metric, codePoint);
427
+ if (raster !== undefined && pageFile !== undefined) {
428
+ pageHashes.add(pageFile.sha256);
409
429
  glyphs.push({
410
430
  kind: 'bitmap',
411
431
  key: `${entry.sourceFile.sha256}:${codePoint}:${Math.max(1, fontSize)}`,
@@ -520,14 +540,14 @@ export class GuiAssetCatalog {
520
540
  this.metrics.set(key, null);
521
541
  return undefined;
522
542
  }
523
- loadBmFontGlyph(entry, pagePath, metric, codePoint) {
543
+ loadBmFontGlyph(entry, pageFile, metric, codePoint) {
524
544
  if (metric.width <= 0 || metric.height <= 0)
525
545
  return Promise.resolve(undefined);
526
- const key = `${entry.sourceFile.sha256}:${pagePath}:${codePoint}:${metric.x}:${metric.y}:${metric.width}:${metric.height}`;
546
+ const key = `${entry.sourceFile.sha256}:${pageFile.sha256}:${codePoint}:${metric.x}:${metric.y}:${metric.width}:${metric.height}`;
527
547
  let promise = this.glyphRasters.get(key);
528
548
  if (promise === undefined) {
529
549
  promise = (async () => {
530
- const raster = await this.loadRaster(pagePath, entry.sourceFile.relativePath);
550
+ const raster = await this.loadRaster(pageFile.relativePath);
531
551
  if (!raster.supported ||
532
552
  metric.x < 0 ||
533
553
  metric.y < 0 ||
@@ -555,6 +575,35 @@ export class GuiAssetCatalog {
555
575
  }
556
576
  return promise;
557
577
  }
578
+ resolveBmFontPage(entry, pagePath, pageIndex) {
579
+ const normalizedPage = normalizeAssetPath(pagePath);
580
+ const relativePage = normalizeAssetPath(path.posix.join(path.posix.dirname(normalizeAssetPath(entry.sourceFile.relativePath)), normalizedPage));
581
+ const rasterExtensions = ['.png', '.bmp', '.tga', '.dds', '.svg'];
582
+ for (const candidate of [relativePage, normalizedPage]) {
583
+ const candidateExtension = path.posix.extname(candidate).toLowerCase();
584
+ const candidateStem = candidate.slice(0, -candidateExtension.length);
585
+ const variants = candidateExtension.length === 0
586
+ ? rasterExtensions.map((suffix) => `${candidate}${suffix}`)
587
+ : rasterExtensions.includes(candidateExtension)
588
+ ? [candidate, ...rasterExtensions.map((suffix) => `${candidateStem}${suffix}`)]
589
+ : [candidate];
590
+ for (const variant of variants) {
591
+ const file = this.files.get(variant);
592
+ if (file !== undefined)
593
+ return file;
594
+ }
595
+ }
596
+ if (pageIndex === 0) {
597
+ const sourcePath = normalizeAssetPath(entry.sourceFile.relativePath);
598
+ const sourceStem = sourcePath.slice(0, -path.posix.extname(sourcePath).length);
599
+ for (const suffix of rasterExtensions) {
600
+ const file = this.files.get(`${sourceStem}${suffix}`);
601
+ if (file !== undefined)
602
+ return file;
603
+ }
604
+ }
605
+ return this.resolveFile(pagePath, entry.sourceFile.relativePath);
606
+ }
558
607
  async decodeRaster(file) {
559
608
  const extension = path.extname(file.relativePath).toLowerCase();
560
609
  const operationKey = `gui-raster:${file.displayPath}:${file.sha256}`;
@@ -42,36 +42,107 @@ function graphCounts(graph) {
42
42
  * directly connected node while recording exact full-inventory counts.
43
43
  */
44
44
  export function projectGuiGraphForArtifact(graph, selectedSourceIds = [], fullGraphNodeLimit = GUI_INSPECTION_FULL_GRAPH_NODE_LIMIT) {
45
- if (graph.nodes.length <= fullGraphNodeLimit)
45
+ const selectedWindow = selectedSourceIds.length > 0;
46
+ if (!selectedWindow && graph.nodes.length <= fullGraphNodeLimit)
46
47
  return { graph };
47
48
  const retainedIds = new Set(selectedSourceIds);
48
- for (const node of graph.nodes)
49
- if (isWorkspaceGraphSeed(node))
50
- retainedIds.add(node.id);
51
- const retainedEdges = graph.edges.filter(({ from, to }) => retainedIds.has(from) || retainedIds.has(to));
52
- for (const edge of retainedEdges) {
49
+ if (selectedWindow) {
50
+ const selectedElementNames = new Set(graph.elements.filter(({ id }) => retainedIds.has(id)).map(({ name }) => name));
51
+ const retainedScriptedGuiNames = new Set(graph.scriptedGuis
52
+ .filter(({ windowName, parentWindowName }) => (windowName !== undefined && selectedElementNames.has(windowName)) ||
53
+ (parentWindowName !== undefined && selectedElementNames.has(parentWindowName)))
54
+ .map(({ name }) => name));
55
+ let addedRelatedScriptedGui = true;
56
+ while (addedRelatedScriptedGui) {
57
+ addedRelatedScriptedGui = false;
58
+ for (const scripted of graph.scriptedGuis) {
59
+ if (scripted.parentScriptedGui === undefined)
60
+ continue;
61
+ if (retainedScriptedGuiNames.has(scripted.parentScriptedGui) &&
62
+ !retainedScriptedGuiNames.has(scripted.name)) {
63
+ retainedScriptedGuiNames.add(scripted.name);
64
+ addedRelatedScriptedGui = true;
65
+ }
66
+ if (retainedScriptedGuiNames.has(scripted.name) &&
67
+ !retainedScriptedGuiNames.has(scripted.parentScriptedGui)) {
68
+ retainedScriptedGuiNames.add(scripted.parentScriptedGui);
69
+ addedRelatedScriptedGui = true;
70
+ }
71
+ }
72
+ }
73
+ for (const scripted of graph.scriptedGuis)
74
+ if (retainedScriptedGuiNames.has(scripted.name))
75
+ retainedIds.add(scripted.id);
76
+ }
77
+ else {
78
+ for (const node of graph.nodes)
79
+ if (isWorkspaceGraphSeed(node))
80
+ retainedIds.add(node.id);
81
+ }
82
+ const retainedEdges = [];
83
+ const retainedEdgeIds = new Set();
84
+ let addedDependency = true;
85
+ while (addedDependency) {
86
+ addedDependency = false;
87
+ for (const edge of graph.edges) {
88
+ if (!retainedIds.has(edge.from) || retainedEdgeIds.has(edge.id))
89
+ continue;
90
+ retainedEdges.push(edge);
91
+ retainedEdgeIds.add(edge.id);
92
+ if (!retainedIds.has(edge.to)) {
93
+ retainedIds.add(edge.to);
94
+ addedDependency = true;
95
+ }
96
+ }
97
+ }
98
+ for (const edge of graph.edges) {
99
+ if (!retainedIds.has(edge.to) ||
100
+ retainedEdgeIds.has(edge.id) ||
101
+ !['contains', 'decision_category_entry'].includes(edge.kind))
102
+ continue;
103
+ retainedEdges.push(edge);
104
+ retainedEdgeIds.add(edge.id);
53
105
  retainedIds.add(edge.from);
54
- retainedIds.add(edge.to);
55
106
  }
56
107
  const retainedLocalisation = new Set(graph.nodes
57
108
  .filter(({ id, kind }) => kind === 'localisation' && retainedIds.has(id))
58
109
  .map(({ name, path }) => `${path}\u0000${name}`));
110
+ const retainedPaths = new Set([
111
+ ...graph.elements.filter(({ id }) => retainedIds.has(id)).map(({ sourcePath }) => sourcePath),
112
+ ...graph.sprites.filter(({ id }) => retainedIds.has(id)).map(({ sourcePath }) => sourcePath),
113
+ ...graph.fonts.filter(({ id }) => retainedIds.has(id)).map(({ sourcePath }) => sourcePath),
114
+ ...graph.scriptedGuis
115
+ .filter(({ id }) => retainedIds.has(id))
116
+ .map(({ sourcePath }) => sourcePath),
117
+ ...graph.animationSources
118
+ .filter(({ id }) => retainedIds.has(id))
119
+ .map(({ sourcePath }) => sourcePath),
120
+ ...graph.scriptedLocalisation
121
+ .filter(({ id }) => retainedIds.has(id))
122
+ .map(({ sourcePath }) => sourcePath),
123
+ ]);
59
124
  const retainedGraph = {
60
125
  ...graph,
61
126
  nodes: graph.nodes.filter(({ id }) => retainedIds.has(id)),
62
127
  edges: retainedEdges,
63
- elements: graph.elements.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
64
- sprites: graph.sprites.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
65
- fonts: graph.fonts.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
66
- scriptedGuis: graph.scriptedGuis.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
67
- animationSources: graph.animationSources.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
68
- scriptedLocalisation: graph.scriptedLocalisation.filter(({ id, sourcePath }) => retainedIds.has(id) || isWorkspaceSource(sourcePath)),
128
+ elements: graph.elements.filter(({ id, sourcePath }) => retainedIds.has(id) || (!selectedWindow && isWorkspaceSource(sourcePath))),
129
+ sprites: graph.sprites.filter(({ id, sourcePath }) => retainedIds.has(id) || (!selectedWindow && isWorkspaceSource(sourcePath))),
130
+ fonts: graph.fonts.filter(({ id, sourcePath }) => retainedIds.has(id) || (!selectedWindow && isWorkspaceSource(sourcePath))),
131
+ scriptedGuis: graph.scriptedGuis.filter(({ id, sourcePath }) => retainedIds.has(id) || (!selectedWindow && isWorkspaceSource(sourcePath))),
132
+ animationSources: graph.animationSources.filter(({ id, sourcePath }) => retainedIds.has(id) || (!selectedWindow && isWorkspaceSource(sourcePath))),
133
+ scriptedLocalisation: graph.scriptedLocalisation.filter(({ id, sourcePath }) => retainedIds.has(id) || (!selectedWindow && isWorkspaceSource(sourcePath))),
69
134
  localisation: graph.localisation.filter(({ key, sourcePath }) => retainedLocalisation.has(`${sourcePath}\u0000${key}`)),
135
+ diagnostics: selectedWindow
136
+ ? graph.diagnostics.filter(({ code, location, related }) => (code === 'GUI_ANIMATION_SOURCE_MANIFEST_INVALID' &&
137
+ location?.path.startsWith('mod:hoi4_agent/animation_sources/') === true) ||
138
+ (location !== undefined && retainedPaths.has(location.path)) ||
139
+ related?.some(({ path }) => retainedPaths.has(path)) === true)
140
+ : graph.diagnostics,
70
141
  };
71
142
  return {
72
143
  graph: retainedGraph,
73
144
  projection: {
74
- mode: 'workspace-overlay-and-selected',
145
+ mode: selectedWindow ? 'selected-window' : 'workspace-overlay-and-selected',
75
146
  fullCounts: graphCounts(graph),
76
147
  returnedCounts: graphCounts(retainedGraph),
77
148
  },