hoi4-agent-tools 2.8.0 → 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 +12 -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/create.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/progress.js +60 -0
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +15 -15
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +6 -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
|
@@ -2,7 +2,7 @@ import sharp from 'sharp';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
|
|
5
|
-
import { compareCodeUnits, canonicalJson, deterministicId, sha256Bytes, } from '../core/canonical.js';
|
|
5
|
+
import { compareCodeUnits, canonicalJson, deterministicId, hashCanonical, sha256Bytes, } from '../core/canonical.js';
|
|
6
6
|
import { DiagnosticCollector } from '../core/diagnostic-collector.js';
|
|
7
7
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
8
8
|
import { CoreEngine } from '../core/engine.js';
|
|
@@ -15,6 +15,7 @@ import { TRANSACTION_MAX_DIAGNOSTICS } from '../core/transaction-limits.js';
|
|
|
15
15
|
import { PACKAGE_VERSION } from '../version.js';
|
|
16
16
|
import { isSafeAnimationSourcePath } from './animation-manifest.js';
|
|
17
17
|
import { GuiAssetCatalog, parseBmFont } from './assets.js';
|
|
18
|
+
import { projectGuiGraphForArtifact } from './inspection-artifact.js';
|
|
18
19
|
import { buildGuiScene } from './layout.js';
|
|
19
20
|
import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
|
|
20
21
|
import { compareGuiImages, galleryDimensions, renderGallerySvg, renderGuiScene, } from './renderer.js';
|
|
@@ -174,6 +175,20 @@ function guiDefinitionPatterns(workspace) {
|
|
|
174
175
|
]),
|
|
175
176
|
].sort((left, right) => compareCodeUnits(left, right));
|
|
176
177
|
}
|
|
178
|
+
function guiLayoutPatterns(workspace) {
|
|
179
|
+
return [...new Set(under(workspace.registration.roots.interface, ['**/*.gui']))].sort((left, right) => compareCodeUnits(left, right));
|
|
180
|
+
}
|
|
181
|
+
function guiLinkedDefinitionPatterns(workspace) {
|
|
182
|
+
const roots = workspace.registration.roots;
|
|
183
|
+
return [
|
|
184
|
+
...new Set([
|
|
185
|
+
...under(roots.interface, ['**/*.gfx']),
|
|
186
|
+
...under(roots.gfx, ['**/*.gfx']),
|
|
187
|
+
...under(roots.scriptedGui, ['**/*.txt']),
|
|
188
|
+
...staticGuiDefinitionPatterns,
|
|
189
|
+
]),
|
|
190
|
+
].sort((left, right) => compareCodeUnits(left, right));
|
|
191
|
+
}
|
|
177
192
|
function guiLocalisationPatterns(workspace, languages = ['l_english']) {
|
|
178
193
|
const roots = workspace.registration.roots;
|
|
179
194
|
const languageDirectories = [...new Set(languages)].map((language) => {
|
|
@@ -206,9 +221,17 @@ function referenceVariants(value) {
|
|
|
206
221
|
if (normalized === undefined)
|
|
207
222
|
return [];
|
|
208
223
|
const extension = path.posix.extname(normalized);
|
|
224
|
+
const rasterExtensions = ['.png', '.bmp', '.tga', '.dds', '.svg'];
|
|
209
225
|
const candidates = extension.length === 0
|
|
210
|
-
? ['.fnt', '.ttf', '.otf', '.woff', '.woff2',
|
|
211
|
-
:
|
|
226
|
+
? ['.fnt', '.ttf', '.otf', '.woff', '.woff2', ...rasterExtensions].map((suffix) => `${normalized}${suffix}`)
|
|
227
|
+
: rasterExtensions.includes(extension.toLowerCase())
|
|
228
|
+
? [
|
|
229
|
+
normalized,
|
|
230
|
+
...rasterExtensions
|
|
231
|
+
.filter((candidate) => candidate !== extension.toLowerCase())
|
|
232
|
+
.map((candidate) => `${normalized.slice(0, -extension.length)}${candidate}`),
|
|
233
|
+
]
|
|
234
|
+
: [normalized];
|
|
212
235
|
return candidates;
|
|
213
236
|
}
|
|
214
237
|
function basenameFallbackPatterns(exactPaths, exactFiles) {
|
|
@@ -223,7 +246,35 @@ function basenameFallbackPatterns(exactPaths, exactFiles) {
|
|
|
223
246
|
}
|
|
224
247
|
function selectedElements(graph, windowName) {
|
|
225
248
|
const byId = new Map(graph.elements.map((element) => [element.id, element]));
|
|
226
|
-
const
|
|
249
|
+
const relatedScriptedGuiNames = new Set(graph.scriptedGuis
|
|
250
|
+
.filter((definition) => definition.windowName === windowName || definition.parentWindowName === windowName)
|
|
251
|
+
.map(({ name }) => name));
|
|
252
|
+
let addedRelatedScriptedGui = true;
|
|
253
|
+
while (addedRelatedScriptedGui) {
|
|
254
|
+
addedRelatedScriptedGui = false;
|
|
255
|
+
for (const definition of graph.scriptedGuis) {
|
|
256
|
+
if (definition.parentScriptedGui === undefined ||
|
|
257
|
+
!relatedScriptedGuiNames.has(definition.parentScriptedGui) ||
|
|
258
|
+
relatedScriptedGuiNames.has(definition.name))
|
|
259
|
+
continue;
|
|
260
|
+
relatedScriptedGuiNames.add(definition.name);
|
|
261
|
+
addedRelatedScriptedGui = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const selectedRootNames = new Set([windowName]);
|
|
265
|
+
for (const scripted of graph.scriptedGuis.filter(({ name }) => relatedScriptedGuiNames.has(name))) {
|
|
266
|
+
if (scripted.windowName !== undefined)
|
|
267
|
+
selectedRootNames.add(scripted.windowName);
|
|
268
|
+
for (const dynamicList of scripted.dynamicListDefinitions) {
|
|
269
|
+
if (dynamicList.entryContainer !== undefined)
|
|
270
|
+
selectedRootNames.add(dynamicList.entryContainer);
|
|
271
|
+
if (dynamicList.countryScopeEntryContainer !== undefined)
|
|
272
|
+
selectedRootNames.add(dynamicList.countryScopeEntryContainer);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const pending = graph.elements
|
|
276
|
+
.filter(({ name }) => selectedRootNames.has(name))
|
|
277
|
+
.map(({ id }) => id);
|
|
227
278
|
const selected = new Map();
|
|
228
279
|
while (pending.length > 0) {
|
|
229
280
|
const id = pending.pop();
|
|
@@ -276,8 +327,8 @@ function localisationIconSpriteCandidates(token) {
|
|
|
276
327
|
const normalized = token.startsWith('GFX_') ? token : `GFX_${token}`;
|
|
277
328
|
return [...new Set([normalized, `${normalized}_texticon`, `${normalized}_text_icon`])];
|
|
278
329
|
}
|
|
279
|
-
function referencedAssetPatternsForWindow(graph, windowName) {
|
|
280
|
-
const spriteNames = new Set();
|
|
330
|
+
function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = []) {
|
|
331
|
+
const spriteNames = new Set(additionalSpriteNames);
|
|
281
332
|
const fontNames = new Set();
|
|
282
333
|
const textValues = new Set();
|
|
283
334
|
for (const element of selectedElements(graph, windowName)) {
|
|
@@ -326,6 +377,15 @@ function referencedAssetPatternsForWindow(graph, windowName) {
|
|
|
326
377
|
];
|
|
327
378
|
return [...new Set(references.flatMap(referenceVariants))].sort((left, right) => compareCodeUnits(left, right));
|
|
328
379
|
}
|
|
380
|
+
function scenarioSpriteNames(scenarios) {
|
|
381
|
+
return [
|
|
382
|
+
...new Set(scenarios.flatMap(({ scriptedGui }) => Object.entries(scriptedGui)
|
|
383
|
+
.filter(([key, value]) => typeof value === 'string' &&
|
|
384
|
+
value.startsWith('GFX_') &&
|
|
385
|
+
(key.endsWith('.image') || !key.includes('.')))
|
|
386
|
+
.map(([, value]) => value))),
|
|
387
|
+
].sort((left, right) => compareCodeUnits(left, right));
|
|
388
|
+
}
|
|
329
389
|
function bmFontPagePatterns(files) {
|
|
330
390
|
const references = files.flatMap((file) => {
|
|
331
391
|
if (!file.relativePath.toLowerCase().endsWith('.fnt') || file.shadowedBy !== undefined)
|
|
@@ -333,7 +393,9 @@ function bmFontPagePatterns(files) {
|
|
|
333
393
|
const metrics = parseBmFont(file.bytes.toString('utf8'));
|
|
334
394
|
if (metrics === undefined)
|
|
335
395
|
return [];
|
|
336
|
-
|
|
396
|
+
const directory = path.posix.dirname(file.relativePath);
|
|
397
|
+
const sourceStem = file.relativePath.slice(0, -path.posix.extname(file.relativePath).length);
|
|
398
|
+
return [...metrics.pages.map((page) => path.posix.join(directory, page)), `${sourceStem}.dds`];
|
|
337
399
|
});
|
|
338
400
|
return [...new Set(references.flatMap(referenceVariants))].sort((left, right) => compareCodeUnits(left, right));
|
|
339
401
|
}
|
|
@@ -343,14 +405,17 @@ function mergeScannedFiles(...groups) {
|
|
|
343
405
|
byDisplayPath.set(file.displayPath, file);
|
|
344
406
|
return [...byDisplayPath.values()].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
345
407
|
}
|
|
346
|
-
function withGraphDiagnostics(graph, validation) {
|
|
347
|
-
const
|
|
408
|
+
function withGraphDiagnostics(graph, validation, selectedSourceIds = []) {
|
|
409
|
+
const graphDiagnostics = selectedSourceIds.length === 0
|
|
410
|
+
? graph.diagnostics
|
|
411
|
+
: projectGuiGraphForArtifact(graph, selectedSourceIds).graph.diagnostics;
|
|
412
|
+
const hard = graphDiagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
|
|
348
413
|
const diagnostics = new DiagnosticCollector(GUI_VALIDATION_MAX_DIAGNOSTICS, {
|
|
349
414
|
code: 'GUI_VALIDATION_DIAGNOSTICS_TRUNCATED',
|
|
350
415
|
category: 'layout',
|
|
351
416
|
message: 'Combined GUI source and validation diagnostics exceeded the fixed result ceiling',
|
|
352
417
|
});
|
|
353
|
-
diagnostics.pushMany(
|
|
418
|
+
diagnostics.pushMany(graphDiagnostics);
|
|
354
419
|
diagnostics.pushMany(validation.diagnostics);
|
|
355
420
|
return {
|
|
356
421
|
diagnostics: sortDiagnostics(diagnostics.values()),
|
|
@@ -517,6 +582,7 @@ export class ScriptedGuiStudio {
|
|
|
517
582
|
transactions;
|
|
518
583
|
scanner;
|
|
519
584
|
artifacts;
|
|
585
|
+
#graphCache = new Map();
|
|
520
586
|
constructor(engineOrResolver, transactions, scanner = new WorkspaceScanner(), artifacts = new ArtifactStore()) {
|
|
521
587
|
this.engine =
|
|
522
588
|
engineOrResolver instanceof CoreEngine
|
|
@@ -531,6 +597,32 @@ export class ScriptedGuiStudio {
|
|
|
531
597
|
this.scanner = this.engine.scanner;
|
|
532
598
|
this.artifacts = this.engine.artifacts;
|
|
533
599
|
}
|
|
600
|
+
graphForFiles(files, scope) {
|
|
601
|
+
const key = `${scope}:${hashCanonical(files.map(({ displayPath, sha256, shadowedBy }) => ({
|
|
602
|
+
displayPath,
|
|
603
|
+
sha256,
|
|
604
|
+
...(shadowedBy === undefined ? {} : { shadowedBy }),
|
|
605
|
+
})))}`;
|
|
606
|
+
const cached = this.#graphCache.get(key);
|
|
607
|
+
if (cached !== undefined) {
|
|
608
|
+
this.#graphCache.delete(key);
|
|
609
|
+
this.#graphCache.set(key, cached);
|
|
610
|
+
return cached;
|
|
611
|
+
}
|
|
612
|
+
const retainedFiles = [...files];
|
|
613
|
+
const scanned = {
|
|
614
|
+
files: retainedFiles,
|
|
615
|
+
graph: buildGuiSourceGraph(retainedFiles, this.engine.indexFiles(retainedFiles)),
|
|
616
|
+
};
|
|
617
|
+
this.#graphCache.set(key, scanned);
|
|
618
|
+
while (this.#graphCache.size > 8) {
|
|
619
|
+
const oldest = this.#graphCache.keys().next().value;
|
|
620
|
+
if (oldest === undefined)
|
|
621
|
+
break;
|
|
622
|
+
this.#graphCache.delete(oldest);
|
|
623
|
+
}
|
|
624
|
+
return scanned;
|
|
625
|
+
}
|
|
534
626
|
async scan(workspaceId, principal, signal, languages = ['l_english']) {
|
|
535
627
|
signal?.throwIfAborted();
|
|
536
628
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
@@ -543,16 +635,36 @@ export class ScriptedGuiStudio {
|
|
|
543
635
|
]);
|
|
544
636
|
signal?.throwIfAborted();
|
|
545
637
|
const files = mergeScannedFiles(snapshot.files, localisationFiles);
|
|
546
|
-
return {
|
|
547
|
-
files,
|
|
548
|
-
graph: buildGuiSourceGraph(files, snapshot.index),
|
|
549
|
-
};
|
|
638
|
+
return this.graphForFiles(files, `workspace:${workspaceId}`);
|
|
550
639
|
}
|
|
551
|
-
async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english']) {
|
|
552
|
-
const definitions = await this.scan(workspaceId, principal, signal, languages);
|
|
553
|
-
assertTargetWindowAvailable(definitions.graph, windowName);
|
|
640
|
+
async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = []) {
|
|
554
641
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
555
|
-
const
|
|
642
|
+
const layoutPatterns = guiLayoutPatterns(workspace);
|
|
643
|
+
const overlayLayouts = await this.engine.scan(workspaceId, {
|
|
644
|
+
patterns: layoutPatterns,
|
|
645
|
+
rootKinds: ['mod', 'dependency', 'fixture'],
|
|
646
|
+
}, principal, signal);
|
|
647
|
+
let layoutFiles = overlayLayouts.files;
|
|
648
|
+
let layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
|
|
649
|
+
if (!layoutGraph.elements.some(({ name }) => name === windowName)) {
|
|
650
|
+
const gameLayouts = await this.engine.scan(workspaceId, { patterns: layoutPatterns, rootKinds: ['game'] }, principal, signal);
|
|
651
|
+
const gameGraph = this.graphForFiles(gameLayouts.files, `game-layout:${workspaceId}`).graph;
|
|
652
|
+
const selectedPaths = new Set(gameGraph.elements
|
|
653
|
+
.filter(({ name }) => name === windowName)
|
|
654
|
+
.map(({ sourcePath }) => sourcePath));
|
|
655
|
+
layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.files.filter(({ displayPath }) => selectedPaths.has(displayPath)));
|
|
656
|
+
layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
|
|
657
|
+
}
|
|
658
|
+
if (!layoutGraph.elements.some(({ name }) => name === windowName))
|
|
659
|
+
assertTargetWindowAvailable(layoutGraph, windowName);
|
|
660
|
+
const [linkedDefinitions, localisation] = await Promise.all([
|
|
661
|
+
this.engine.scan(workspaceId, { patterns: guiLinkedDefinitionPatterns(workspace) }, principal, signal),
|
|
662
|
+
this.engine.scan(workspaceId, { patterns: guiLocalisationPatterns(workspace, languages) }, principal, signal),
|
|
663
|
+
]);
|
|
664
|
+
const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions.files, localisation.files);
|
|
665
|
+
const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`);
|
|
666
|
+
assertTargetWindowAvailable(definitions.graph, windowName);
|
|
667
|
+
const referencedPatterns = referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames);
|
|
556
668
|
const exactReferenced = referencedPatterns.length === 0
|
|
557
669
|
? []
|
|
558
670
|
: await this.scanner.scan(workspace, {
|
|
@@ -586,23 +698,24 @@ export class ScriptedGuiStudio {
|
|
|
586
698
|
const fontPages = mergeScannedFiles(exactFontPages, fallbackFontPages);
|
|
587
699
|
signal?.throwIfAborted();
|
|
588
700
|
const files = mergeScannedFiles(definitions.files, referenced, fontPages);
|
|
589
|
-
return
|
|
701
|
+
return this.graphForFiles(files, `window:${workspaceId}:${windowName}`);
|
|
590
702
|
}
|
|
591
703
|
async lint(input) {
|
|
592
704
|
input.signal?.throwIfAborted();
|
|
593
705
|
const scenario = parsePreviewScenario(input.scenario);
|
|
594
706
|
const relatedScenarios = (input.relatedScenarios ?? []).map(parsePreviewScenario);
|
|
595
707
|
assertUniqueScenarioIds([scenario, ...relatedScenarios]);
|
|
596
|
-
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [scenario.language, ...relatedScenarios.map(({ language }) => language)]);
|
|
708
|
+
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [scenario.language, ...relatedScenarios.map(({ language }) => language)], scenarioSpriteNames([scenario, ...relatedScenarios]));
|
|
597
709
|
const budget = new RenderBudget();
|
|
598
|
-
const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget);
|
|
710
|
+
const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
|
|
599
711
|
const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
|
|
600
712
|
const relatedScenes = [];
|
|
601
713
|
for (const related of relatedScenarios) {
|
|
602
714
|
input.signal?.throwIfAborted();
|
|
603
715
|
relatedScenes.push(await buildGuiScene(scanned.graph, scanned.files, input.windowName, related, catalog));
|
|
604
716
|
}
|
|
605
|
-
const
|
|
717
|
+
const validationGraph = projectGuiGraphForArtifact(scanned.graph, [scene, ...relatedScenes].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId))).graph;
|
|
718
|
+
const validation = withGraphDiagnostics(validationGraph, await validateGuiScene(validationGraph, scene, scanned.files, relatedScenes, catalog));
|
|
606
719
|
return { scene, graph: scanned.graph, validation };
|
|
607
720
|
}
|
|
608
721
|
async compare(input) {
|
|
@@ -610,10 +723,11 @@ export class ScriptedGuiStudio {
|
|
|
610
723
|
const budget = new RenderBudget();
|
|
611
724
|
const beforeScenario = parsePreviewScenario(input.before);
|
|
612
725
|
const afterScenario = parsePreviewScenario(input.after);
|
|
613
|
-
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language]);
|
|
614
|
-
const
|
|
615
|
-
const
|
|
616
|
-
const
|
|
726
|
+
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]));
|
|
727
|
+
const beforeCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, beforeScenario.language);
|
|
728
|
+
const afterCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, afterScenario.language);
|
|
729
|
+
const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, beforeCatalog);
|
|
730
|
+
const after = await buildGuiScene(scanned.graph, scanned.files, input.windowName, afterScenario, afterCatalog);
|
|
617
731
|
const [beforeRender, afterRender] = await Promise.all([
|
|
618
732
|
renderGuiScene(before, ['full'], input.signal, budget),
|
|
619
733
|
renderGuiScene(after, ['full'], input.signal, budget),
|
|
@@ -650,8 +764,8 @@ export class ScriptedGuiStudio {
|
|
|
650
764
|
scenario.language,
|
|
651
765
|
baselineScenario.language,
|
|
652
766
|
...relatedScenarios.map(({ language }) => language),
|
|
653
|
-
]);
|
|
654
|
-
const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget);
|
|
767
|
+
], scenarioSpriteNames([scenario, baselineScenario, ...relatedScenarios]));
|
|
768
|
+
const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
|
|
655
769
|
const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
|
|
656
770
|
const render = await renderGuiScene(scene, undefined, input.signal, budget);
|
|
657
771
|
const scenarioScenes = [scene];
|
|
@@ -715,12 +829,19 @@ export class ScriptedGuiStudio {
|
|
|
715
829
|
const baselineScene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, baselineScenario, catalog);
|
|
716
830
|
const baselineRender = await renderGuiScene(baselineScene, ['full'], input.signal, budget);
|
|
717
831
|
const comparison = await compareGuiImages(fullImage(baselineRender).png, fullImage(render).png, budget, input.signal);
|
|
718
|
-
const
|
|
832
|
+
const renderedSourceIds = [
|
|
833
|
+
...scenarioScenes,
|
|
834
|
+
...stateScenes,
|
|
835
|
+
...resolutionScenes,
|
|
836
|
+
baselineScene,
|
|
837
|
+
].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId));
|
|
838
|
+
const validationGraph = projectGuiGraphForArtifact(scanned.graph, renderedSourceIds).graph;
|
|
839
|
+
const validation = await validateGuiScene(validationGraph, scene, scanned.files, scenarioScenes.slice(1), catalog);
|
|
719
840
|
const stateValidation = validateStateMatrix(stateScenes);
|
|
720
841
|
const resolutionValidation = validateResolutionDrift(resolutionScenes);
|
|
721
842
|
validation.diagnostics.push(...stateValidation.diagnostics, ...resolutionValidation.diagnostics);
|
|
722
843
|
validation.checks.push(...stateValidation.checks, ...resolutionValidation.checks);
|
|
723
|
-
const completeValidation = withGraphDiagnostics(
|
|
844
|
+
const completeValidation = withGraphDiagnostics(validationGraph, validation);
|
|
724
845
|
const scenarioGallerySvg = renderGallerySvg(`${input.windowName} scripted scenario matrix`, scenarioItems);
|
|
725
846
|
const stateGallerySvg = renderGallerySvg(`${input.windowName} state matrix`, stateItems);
|
|
726
847
|
const resolutionGallerySvg = renderGallerySvg(`${input.windowName} resolution and UI-scale matrix`, resolutionItems);
|
|
@@ -746,6 +867,9 @@ export class ScriptedGuiStudio {
|
|
|
746
867
|
.toBuffer(),
|
|
747
868
|
]);
|
|
748
869
|
const slug = safeSlug(input.windowName);
|
|
870
|
+
const projectedArtifactGraph = projectGuiGraphForArtifact(scanned.graph, [
|
|
871
|
+
...new Set([...scenarioScenes, ...stateScenes, ...resolutionScenes, baselineScene].flatMap(({ elements }) => elements.map(({ sourceId }) => sourceId))),
|
|
872
|
+
]);
|
|
749
873
|
const writes = [];
|
|
750
874
|
const add = (name, mimeType, content, kind, scenarioScenes = [scene], metadata = {}) => {
|
|
751
875
|
input.signal?.throwIfAborted();
|
|
@@ -764,7 +888,13 @@ export class ScriptedGuiStudio {
|
|
|
764
888
|
add(`${slug}-layout.json`, 'application/json', render.layoutJson, 'gui-layout-json');
|
|
765
889
|
add(`${slug}-scenario.json`, 'application/json', render.scenarioJson, 'gui-preview-scenario');
|
|
766
890
|
add(`${slug}-fidelity.json`, 'application/json', `${canonicalJson({ offline: true, fidelity: render.fidelity })}\n`, 'gui-fidelity-report');
|
|
767
|
-
add(`${slug}-source-graph.json`, 'application/json', `${canonicalJson(
|
|
891
|
+
add(`${slug}-source-graph.json`, 'application/json', `${canonicalJson({
|
|
892
|
+
schemaVersion: 1,
|
|
893
|
+
graph: projectedArtifactGraph.graph,
|
|
894
|
+
...(projectedArtifactGraph.projection === undefined
|
|
895
|
+
? {}
|
|
896
|
+
: { graphProjection: projectedArtifactGraph.projection }),
|
|
897
|
+
})}\n`, 'gui-source-graph');
|
|
768
898
|
add(`${slug}-validation.json`, 'application/json', `${canonicalJson(completeValidation)}\n`, 'gui-validation-report', [...scenarioScenes, ...stateScenes, ...resolutionScenes]);
|
|
769
899
|
add(`${slug}-scenario-matrix.svg`, 'image/svg+xml', scenarioGallerySvg, 'gui-scripted-scenario-matrix', scenarioScenes);
|
|
770
900
|
add(`${slug}-scenario-matrix.png`, 'image/png', scenarioGalleryPng, 'gui-scripted-scenario-matrix', scenarioScenes);
|
|
@@ -910,9 +1040,10 @@ export class ScriptedGuiStudio {
|
|
|
910
1040
|
});
|
|
911
1041
|
if ((input.windowName === undefined) !== (input.scenario === undefined))
|
|
912
1042
|
throw new Error('GUI visual preflight requires both windowName and scenario.');
|
|
1043
|
+
const previewScenario = input.scenario === undefined ? undefined : parsePreviewScenario(input.scenario);
|
|
913
1044
|
const initiallyScanned = input.windowName === undefined
|
|
914
1045
|
? await this.scan(input.workspaceId, input.principal, input.signal)
|
|
915
|
-
: await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [
|
|
1046
|
+
: await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]));
|
|
916
1047
|
const exactTargets = await this.scanner.scan(workspace, {
|
|
917
1048
|
patterns: prepared.map(({ relativePath }) => relativePath),
|
|
918
1049
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
@@ -970,8 +1101,9 @@ export class ScriptedGuiStudio {
|
|
|
970
1101
|
const proposedScene = await buildGuiScene(proposedGraph, proposedFiles, input.windowName, scenario, proposedCatalog);
|
|
971
1102
|
const proposedRender = await renderGuiScene(proposedScene, ['full'], input.signal, renderBudget);
|
|
972
1103
|
const proposedImage = fullImage(proposedRender);
|
|
973
|
-
const
|
|
974
|
-
|
|
1104
|
+
const proposedValidationGraph = projectGuiGraphForArtifact(proposedGraph, proposedScene.elements.map(({ sourceId }) => sourceId)).graph;
|
|
1105
|
+
const proposedValidation = await validateGuiScene(proposedValidationGraph, proposedScene, proposedFiles, [], proposedCatalog);
|
|
1106
|
+
preflightDiagnostics.push(...proposedValidationGraph.diagnostics, ...proposedScene.diagnostics, ...proposedValidation.diagnostics);
|
|
975
1107
|
const visualPassed = proposedValidation.checks.every(({ passed }) => passed) &&
|
|
976
1108
|
!preflightDiagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker');
|
|
977
1109
|
preflightChecks.push(...proposedValidation.checks, {
|
|
@@ -301,8 +301,23 @@ function actionElementName(action) {
|
|
|
301
301
|
.replace(/_(?:visible|enabled)$/u, '');
|
|
302
302
|
}
|
|
303
303
|
function attachedScriptedGuis(graph, scene) {
|
|
304
|
-
|
|
305
|
-
definition.
|
|
304
|
+
const attachedNames = new Set(graph.scriptedGuis
|
|
305
|
+
.filter((definition) => definition.windowName === scene.windowName ||
|
|
306
|
+
definition.parentWindowName === scene.windowName)
|
|
307
|
+
.map(({ name }) => name));
|
|
308
|
+
let added = true;
|
|
309
|
+
while (added) {
|
|
310
|
+
added = false;
|
|
311
|
+
for (const definition of graph.scriptedGuis) {
|
|
312
|
+
if (definition.parentScriptedGui === undefined ||
|
|
313
|
+
!attachedNames.has(definition.parentScriptedGui) ||
|
|
314
|
+
attachedNames.has(definition.name))
|
|
315
|
+
continue;
|
|
316
|
+
attachedNames.add(definition.name);
|
|
317
|
+
added = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return graph.scriptedGuis.filter(({ name }) => attachedNames.has(name));
|
|
306
321
|
}
|
|
307
322
|
function normalizedCostResource(value) {
|
|
308
323
|
const normalized = value.toLowerCase().replace(/_icon$/u, '');
|
|
@@ -410,8 +425,6 @@ function validateOverlapAndGeometry(scene, diagnostics, work) {
|
|
|
410
425
|
if (element.clickable && element.visible && !sameRect(element.rect, element.unclippedRect)) {
|
|
411
426
|
diagnostics.push(issue('GUI_CLICK_BOUNDS_MISMATCH', 'warning', 'design', `${element.name} has visible/clipped bounds that differ from its source click rectangle.`, element));
|
|
412
427
|
}
|
|
413
|
-
if (element.clickable && !element.visible)
|
|
414
|
-
diagnostics.push(issue('GUI_INVISIBLE_CLICK_BLOCKER', 'error', 'design', `${element.name} remains clickable while invisible in this scenario.`, element));
|
|
415
428
|
}
|
|
416
429
|
const visiblePairCount = (visible.length * (visible.length - 1)) / 2;
|
|
417
430
|
if (work.admitPairs(visiblePairCount, 'GUI overlap validation', diagnostics)) {
|
|
@@ -547,20 +560,16 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
|
|
|
547
560
|
diagnostics.push(issue('GUI_RENDER_FIELD_UNSUPPORTED', 'info', 'rendering', `${element.name}.${field} is preserved in source but not reliably modelled.`, element));
|
|
548
561
|
}
|
|
549
562
|
const validContexts = new Set([
|
|
550
|
-
'
|
|
551
|
-
'
|
|
552
|
-
'
|
|
553
|
-
'operative',
|
|
554
|
-
'character',
|
|
555
|
-
'division',
|
|
563
|
+
'player_context',
|
|
564
|
+
'selected_country_context',
|
|
565
|
+
'selected_state_context',
|
|
556
566
|
'decision',
|
|
557
567
|
'decision_category',
|
|
558
|
-
'
|
|
559
|
-
'
|
|
560
|
-
'
|
|
561
|
-
'
|
|
562
|
-
'
|
|
563
|
-
'special_project',
|
|
568
|
+
'diplomatic_action',
|
|
569
|
+
'diplomacy_target_context',
|
|
570
|
+
'national_focus_context',
|
|
571
|
+
'country_mapicon',
|
|
572
|
+
'state_mapicon',
|
|
564
573
|
]);
|
|
565
574
|
const names = new Set(graph.scriptedGuis.map(({ name }) => name));
|
|
566
575
|
const windowNames = new Set(graph.elements.map(({ name }) => name));
|
|
@@ -576,10 +585,10 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
|
|
|
576
585
|
}
|
|
577
586
|
else if (!validContexts.has(scripted.contextType)) {
|
|
578
587
|
diagnostics.push({
|
|
579
|
-
code: '
|
|
580
|
-
severity: '
|
|
588
|
+
code: 'GUI_SCRIPTED_CONTEXT_UNKNOWN',
|
|
589
|
+
severity: 'warning',
|
|
581
590
|
category: 'reference',
|
|
582
|
-
message: `Scripted GUI ${scripted.name} uses
|
|
591
|
+
message: `Scripted GUI ${scripted.name} uses context_type ${scripted.contextType}, which is not in the renderer's installed-game and documented context inventory.`,
|
|
583
592
|
...(scripted.location === undefined ? {} : { location: scripted.location }),
|
|
584
593
|
});
|
|
585
594
|
}
|
|
@@ -612,10 +621,20 @@ function validateReferencesAndScript(graph, scene, diagnostics) {
|
|
|
612
621
|
if (attached.length > 0) {
|
|
613
622
|
const effects = new Set(attached.flatMap(({ effects }) => effects.map(actionElementName)));
|
|
614
623
|
const triggers = new Set(attached.flatMap(({ triggers }) => triggers.map(actionElementName)));
|
|
624
|
+
const elementsById = new Map(graph.elements.map((element) => [element.id, element]));
|
|
615
625
|
for (const button of scene.elements.filter(({ elementType }) => /button/iu.test(elementType))) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
626
|
+
const source = elementsById.get(button.sourceId);
|
|
627
|
+
const tooltipOnly = !effects.has(button.name) &&
|
|
628
|
+
!triggers.has(button.name) &&
|
|
629
|
+
(typeof source?.attributes.pdx_tooltip === 'string' ||
|
|
630
|
+
typeof source?.attributes.pdx_tooltip_delayed === 'string') &&
|
|
631
|
+
source.attributes.text === undefined &&
|
|
632
|
+
source.attributes.buttonText === undefined;
|
|
633
|
+
if (!effects.has(button.name) && !triggers.has(button.name) && !tooltipOnly)
|
|
634
|
+
diagnostics.push(issue('GUI_BUTTON_EFFECT_MISSING', 'error', 'reference', `Button ${button.name} has no matching scripted GUI click effect or tooltip-only role.`, button));
|
|
635
|
+
if (triggers.has(button.name) && !effects.has(button.name))
|
|
636
|
+
diagnostics.push(issue('GUI_BUTTON_EFFECT_MISSING', 'warning', 'reference', `Button ${button.name} is controlled by a scripted GUI trigger but has no matching click effect.`, button));
|
|
637
|
+
if (effects.has(button.name) && !triggers.has(button.name))
|
|
619
638
|
diagnostics.push(issue('GUI_BUTTON_TRIGGER_MISSING', 'warning', 'reference', `Button ${button.name} has no matching scripted GUI visibility/enabled trigger.`, button));
|
|
620
639
|
}
|
|
621
640
|
for (const scripted of attached.filter(({ effects, aiEnabled }) => effects.length > 0 && !aiEnabled)) {
|
|
@@ -1036,13 +1055,15 @@ function validationResult(diagnostics, codes) {
|
|
|
1036
1055
|
return {
|
|
1037
1056
|
diagnostics: sorted,
|
|
1038
1057
|
checks: codes.map((code) => {
|
|
1039
|
-
const
|
|
1058
|
+
const matching = sorted.filter((diagnostic) => diagnostic.code === code);
|
|
1059
|
+
const count = matching.length;
|
|
1060
|
+
const blockingCount = matching.filter(({ severity }) => severity === 'error' || severity === 'blocker').length;
|
|
1040
1061
|
return {
|
|
1041
1062
|
id: code.toLowerCase(),
|
|
1042
|
-
passed:
|
|
1063
|
+
passed: blockingCount === 0,
|
|
1043
1064
|
message: count === 0
|
|
1044
1065
|
? `${code} not detected.`
|
|
1045
|
-
: `${code} detected ${count} time${count === 1 ? '' : 's'}.`,
|
|
1066
|
+
: `${code} detected ${count} time${count === 1 ? '' : 's'}${blockingCount === 0 ? ' as non-blocking findings' : `, including ${blockingCount} blocking finding${blockingCount === 1 ? '' : 's'}`}.`,
|
|
1046
1067
|
};
|
|
1047
1068
|
}),
|
|
1048
1069
|
};
|
|
@@ -1112,7 +1133,6 @@ export async function validateGuiScene(graph, scene, files, relatedScenes = [],
|
|
|
1112
1133
|
'GUI_INVALID_SIZE',
|
|
1113
1134
|
'GUI_CHILD_OUTSIDE_CLIPPED_PARENT',
|
|
1114
1135
|
'GUI_CLICK_BOUNDS_MISMATCH',
|
|
1115
|
-
'GUI_INVISIBLE_CLICK_BLOCKER',
|
|
1116
1136
|
'GUI_CONFLICTING_CLICK_REGIONS',
|
|
1117
1137
|
'GUI_MISSING_SPRITE',
|
|
1118
1138
|
'GUI_MISSING_TEXTURE',
|
|
@@ -1122,7 +1142,7 @@ export async function validateGuiScene(graph, scene, files, relatedScenes = [],
|
|
|
1122
1142
|
'GUI_ANIMATION_SHEET_DIMENSION_INVALID',
|
|
1123
1143
|
'GUI_ANIMATION_STATIC_FALLBACK_MISSING',
|
|
1124
1144
|
'GUI_PARENT_WINDOW_INVALID',
|
|
1125
|
-
'
|
|
1145
|
+
'GUI_SCRIPTED_CONTEXT_UNKNOWN',
|
|
1126
1146
|
'GUI_Z_ORDER_RISK',
|
|
1127
1147
|
'GUI_SCROLL_ROW_CUTOFF',
|
|
1128
1148
|
'GUI_RESOLUTION_DRIFT',
|
|
@@ -8,7 +8,7 @@ import { registerEventTools } from '../tools/event.js';
|
|
|
8
8
|
import { registerTechnologyTools } from '../tools/technology.js';
|
|
9
9
|
import { registerProbabilityTools } from '../tools/probability.js';
|
|
10
10
|
import { registerMcpResources } from '../resources/register.js';
|
|
11
|
-
export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Large evidence is linked as resources.';
|
|
11
|
+
export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Long GUI calls emit periodic progress heartbeats when the client requests progress. Large evidence is linked as resources.';
|
|
12
12
|
export function createMcpServer(engine, context = {}) {
|
|
13
13
|
const server = new McpServer({
|
|
14
14
|
name: PACKAGE_NAME,
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interval used for long-running MCP operations. It is shorter than the request timeout used by
|
|
3
|
+
* common MCP clients, while still keeping notification traffic negligible for ordinary calls.
|
|
4
|
+
*/
|
|
5
|
+
export const PROGRESS_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
1
6
|
export function progressReporter(extra) {
|
|
2
7
|
let latest = Number.NEGATIVE_INFINITY;
|
|
8
|
+
let latestTotal = 1;
|
|
9
|
+
let latestMessage = '';
|
|
3
10
|
return {
|
|
4
11
|
signal: extra.signal,
|
|
5
12
|
async report(progress, total, message) {
|
|
6
13
|
extra.signal.throwIfAborted();
|
|
14
|
+
latestTotal = total;
|
|
15
|
+
latestMessage = message;
|
|
7
16
|
const normalized = Math.max(latest, Math.min(progress, total));
|
|
8
17
|
const progressToken = extra._meta?.progressToken;
|
|
9
18
|
if (progressToken === undefined)
|
|
@@ -16,5 +25,56 @@ export function progressReporter(extra) {
|
|
|
16
25
|
params: { progressToken, progress: normalized, total, message },
|
|
17
26
|
});
|
|
18
27
|
},
|
|
28
|
+
async pulse(message = latestMessage) {
|
|
29
|
+
extra.signal.throwIfAborted();
|
|
30
|
+
const progressToken = extra._meta?.progressToken;
|
|
31
|
+
if (progressToken === undefined)
|
|
32
|
+
return;
|
|
33
|
+
await extra.sendNotification({
|
|
34
|
+
method: 'notifications/progress',
|
|
35
|
+
params: {
|
|
36
|
+
progressToken,
|
|
37
|
+
progress: Number.isFinite(latest) ? latest : 0,
|
|
38
|
+
total: latestTotal,
|
|
39
|
+
message,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Keep an MCP request alive while a domain service is in a long, opaque asynchronous stage.
|
|
47
|
+
* Progress notifications are best-effort: a disconnected client must not turn a successful
|
|
48
|
+
* server operation into a partial result, while the request's AbortSignal still cancels work.
|
|
49
|
+
*/
|
|
50
|
+
export async function withProgressHeartbeat(operation, reporter, message, intervalMs = PROGRESS_HEARTBEAT_INTERVAL_MS) {
|
|
51
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
52
|
+
throw new RangeError('Progress heartbeat interval must be a positive finite number');
|
|
53
|
+
}
|
|
54
|
+
reporter.signal.throwIfAborted();
|
|
55
|
+
let active = true;
|
|
56
|
+
let inFlight = false;
|
|
57
|
+
const tick = () => {
|
|
58
|
+
if (!active || inFlight || reporter.signal.aborted)
|
|
59
|
+
return;
|
|
60
|
+
inFlight = true;
|
|
61
|
+
void reporter
|
|
62
|
+
.pulse(message)
|
|
63
|
+
.catch(() => undefined)
|
|
64
|
+
.finally(() => {
|
|
65
|
+
inFlight = false;
|
|
66
|
+
});
|
|
19
67
|
};
|
|
68
|
+
const timer = setInterval(tick, intervalMs);
|
|
69
|
+
timer.unref();
|
|
70
|
+
// Tell the client which opaque stage has started immediately. Waiting for the first interval
|
|
71
|
+
// leaves short-but-expensive stages silent and makes keep-alive behavior scheduler-dependent.
|
|
72
|
+
tick();
|
|
73
|
+
try {
|
|
74
|
+
return await (typeof operation === 'function' ? operation() : operation);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
active = false;
|
|
78
|
+
clearInterval(timer);
|
|
79
|
+
}
|
|
20
80
|
}
|