hoi4-agent-tools 2.8.1 → 2.8.2
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.
- package/CHANGELOG.md +8 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +126 -4
- package/dist/hoi4_agent_tools/core/index.js +3 -1
- package/dist/hoi4_agent_tools/core/scanner.js +43 -2
- package/dist/hoi4_agent_tools/gui/assets.js +63 -14
- package/dist/hoi4_agent_tools/gui/inspection-artifact.js +85 -14
- package/dist/hoi4_agent_tools/gui/layout.js +175 -24
- package/dist/hoi4_agent_tools/gui/source-graph.js +105 -6
- package/dist/hoi4_agent_tools/gui/studio.js +166 -34
- package/dist/hoi4_agent_tools/gui/validators.js +47 -27
- package/dist/hoi4_agent_tools/mcp/server/progress.js +3 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +4 -0
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes.
|
|
6
6
|
|
|
7
|
+
## 2.8.2 - 2026-08-27
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- Rendered language-correct HOI4 bitmap fonts and recovered installed-game first-page atlases whose actual filename omits the declared page suffix.
|
|
12
|
+
- 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.
|
|
13
|
+
- Made concurrent artifact accounting tolerate entries that finish publishing between enumeration and inspection, and emit the first long-operation progress heartbeat immediately.
|
|
14
|
+
|
|
7
15
|
## 2.8.1 - 2026-08-27
|
|
8
16
|
|
|
9
17
|
- 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(
|
|
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' ||
|
|
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
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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,
|
|
405
|
-
if (raster !== undefined &&
|
|
406
|
-
|
|
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,
|
|
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}:${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
},
|