hoi4-agent-tools 2.0.0 → 2.1.0
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 +14 -1
- package/README.md +4 -3
- package/dist/hoi4_agent_tools/core/artifacts.js +109 -4
- package/dist/hoi4_agent_tools/core/diagnostic-collector.js +28 -8
- package/dist/hoi4_agent_tools/core/diff.js +162 -4
- package/dist/hoi4_agent_tools/core/engine.js +2 -0
- package/dist/hoi4_agent_tools/core/index.js +17 -6
- package/dist/hoi4_agent_tools/core/render-budget.js +5 -3
- package/dist/hoi4_agent_tools/core/scanner.js +3 -2
- package/dist/hoi4_agent_tools/core/source/limits.js +4 -1
- package/dist/hoi4_agent_tools/event/graph.js +28 -6
- package/dist/hoi4_agent_tools/event/limits.js +1 -1
- package/dist/hoi4_agent_tools/event/service.js +103 -7
- package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
- package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
- package/dist/hoi4_agent_tools/focus/render.js +27 -16
- package/dist/hoi4_agent_tools/gui/assets.js +9 -2
- package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
- package/dist/hoi4_agent_tools/gui/layout.js +7 -3
- package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
- package/dist/hoi4_agent_tools/gui/studio.js +23 -8
- package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
- package/dist/hoi4_agent_tools/map/model.js +22 -4
- package/dist/hoi4_agent_tools/map/service.js +4 -3
- package/dist/hoi4_agent_tools/map/validation.js +3 -2
- package/dist/hoi4_agent_tools/mcp/server/create.js +1 -1
- package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +145 -223
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +29 -22
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/events.md +4 -5
- package/docs/focus.md +5 -4
- package/docs/gui.md +4 -2
- package/docs/map.md +1 -1
- package/docs/setup.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -7,6 +7,8 @@ import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES, GUI_S
|
|
|
7
7
|
import { guiElementAttributeFidelity } from './source-graph.js';
|
|
8
8
|
import { emptyFidelityReport } from './types.js';
|
|
9
9
|
const clickableTypes = /(?:button|checkbox|editbox|scrollbar|progressbar)/iu;
|
|
10
|
+
const numericDynamicPlaceholder = '[X]';
|
|
11
|
+
const textDynamicPlaceholder = '[dynamic_loc]';
|
|
10
12
|
const partialSpriteSemantics = {
|
|
11
13
|
textSpriteType: {
|
|
12
14
|
field: 'text_sprite_semantics',
|
|
@@ -215,13 +217,15 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
|
|
|
215
217
|
const replacement = rowValues?.[key] ?? scenario.variables[key] ?? scenario.scriptedGui[key];
|
|
216
218
|
if (replacement === undefined) {
|
|
217
219
|
unresolved.push(match[0]);
|
|
218
|
-
return
|
|
220
|
+
return /(?:^|\.)Get[A-Za-z0-9_]+$/u.test(key)
|
|
221
|
+
? textDynamicPlaceholder
|
|
222
|
+
: numericDynamicPlaceholder;
|
|
219
223
|
}
|
|
220
224
|
return String(replacement);
|
|
221
225
|
}, 'GUI variable substitution');
|
|
222
226
|
text = replaceTextBounded(text, /\[([A-Za-z0-9_.:-]+)\]/gu, (match) => {
|
|
223
227
|
const key = match[1] ?? '';
|
|
224
|
-
if (key === 'X')
|
|
228
|
+
if (key === 'X' || key === 'dynamic_loc')
|
|
225
229
|
return match[0];
|
|
226
230
|
const countryKey = key.replace(/^(?:ROOT|This)\./u, '');
|
|
227
231
|
const replacement = rowValues?.[countryKey] ??
|
|
@@ -230,7 +234,7 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
|
|
|
230
234
|
scenario.scriptedGui[key];
|
|
231
235
|
if (replacement === undefined) {
|
|
232
236
|
unresolved.push(match[0]);
|
|
233
|
-
return
|
|
237
|
+
return textDynamicPlaceholder;
|
|
234
238
|
}
|
|
235
239
|
return String(replacement);
|
|
236
240
|
}, 'GUI scope substitution');
|
|
@@ -736,19 +736,19 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
|
|
|
736
736
|
}
|
|
737
737
|
}
|
|
738
738
|
for (const element of elements) {
|
|
739
|
-
const spriteName = [element.attributes.spriteType, element.attributes.quadTextureSprite].find((value) => typeof value === 'string');
|
|
739
|
+
const spriteName = [element.attributes.spriteType, element.attributes.quadTextureSprite].find((value) => typeof value === 'string' && value.trim().length > 0);
|
|
740
740
|
if (spriteName !== undefined) {
|
|
741
741
|
const sprite = spriteByName.get(spriteName);
|
|
742
742
|
addEdge(edges, 'uses_sprite', element.id, sprite?.id ?? `sprite:${spriteName}`, sprite !== undefined, { spriteName }, element.location);
|
|
743
743
|
}
|
|
744
|
-
const fontName = [element.attributes.font, element.attributes.buttonFont].find((value) => typeof value === 'string');
|
|
744
|
+
const fontName = [element.attributes.font, element.attributes.buttonFont].find((value) => typeof value === 'string' && value.trim().length > 0);
|
|
745
745
|
if (fontName !== undefined) {
|
|
746
746
|
const font = fontByName.get(fontName);
|
|
747
747
|
addEdge(edges, 'uses_font', element.id, font?.id ?? `font:${fontName}`, font !== undefined, { fontName }, element.location);
|
|
748
748
|
}
|
|
749
|
-
for (const key of
|
|
749
|
+
for (const key of localisationAttributeNames) {
|
|
750
750
|
const value = element.attributes[key];
|
|
751
|
-
if (typeof value !== 'string')
|
|
751
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
752
752
|
continue;
|
|
753
753
|
const entry = localisationByKey.get(value);
|
|
754
754
|
addEdge(edges, 'uses_localisation', element.id, entry === undefined ? `localisation:${value}` : deterministicId('gui_loc', entry), entry !== undefined, { field: key, key: value }, element.location);
|
|
@@ -800,6 +800,24 @@ function linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scripte
|
|
|
800
800
|
addEdge(edges, 'decision_category_entry', pending.from, target?.id ?? `scripted_gui:${pending.target}`, target !== undefined, { scriptedGui: pending.target }, pending.location);
|
|
801
801
|
}
|
|
802
802
|
}
|
|
803
|
+
const localisationAttributeNames = [
|
|
804
|
+
'text',
|
|
805
|
+
'buttonText',
|
|
806
|
+
'pdx_tooltip',
|
|
807
|
+
'pdx_tooltip_delayed',
|
|
808
|
+
'hint_tag',
|
|
809
|
+
];
|
|
810
|
+
function referencedGuiLocalisationKeys(elements, scriptedLocalisation) {
|
|
811
|
+
const keys = new Set(scriptedLocalisation.flatMap(({ localisationKeys }) => localisationKeys));
|
|
812
|
+
for (const element of elements) {
|
|
813
|
+
for (const attribute of localisationAttributeNames) {
|
|
814
|
+
const value = element.attributes[attribute];
|
|
815
|
+
if (typeof value === 'string' && value.trim().length > 0)
|
|
816
|
+
keys.add(value);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return keys;
|
|
820
|
+
}
|
|
803
821
|
function skippedInventoryCouldResolve(edge, sharedIndex) {
|
|
804
822
|
switch (edge.kind) {
|
|
805
823
|
case 'uses_sprite':
|
|
@@ -837,6 +855,10 @@ function* referenceDiagnostics(edges) {
|
|
|
837
855
|
edge.kind === 'animation_source_frame' ||
|
|
838
856
|
(edge.kind === 'uses_font' && edge.metadata.assetPath !== undefined))
|
|
839
857
|
continue;
|
|
858
|
+
if (edge.location !== undefined &&
|
|
859
|
+
!edge.location.path.startsWith('mod:') &&
|
|
860
|
+
!edge.location.path.startsWith('fixture:'))
|
|
861
|
+
continue;
|
|
840
862
|
const partial = edge.partialInventory === true;
|
|
841
863
|
yield {
|
|
842
864
|
code: partial ? 'GUI_REFERENCE_UNRESOLVED_PARTIAL' : 'GUI_REFERENCE_UNRESOLVED',
|
|
@@ -905,6 +927,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
|
|
|
905
927
|
const fontAssetNodes = new Map();
|
|
906
928
|
const animationFrameNodes = new Map();
|
|
907
929
|
const pendingDecisionEdges = [];
|
|
930
|
+
const localisationFiles = [];
|
|
908
931
|
for (const file of [...activeFiles].sort((a, b) => compareCodeUnits(a.displayPath, b.displayPath))) {
|
|
909
932
|
sourceHashes[file.displayPath] = file.sha256;
|
|
910
933
|
if (sharedIndex.isSourceSkipped(file.displayPath))
|
|
@@ -968,28 +991,7 @@ export function buildGuiSourceGraph(files, sharedIndex) {
|
|
|
968
991
|
}
|
|
969
992
|
}
|
|
970
993
|
if (kind === 'localisation_file' && fileNodeId !== undefined) {
|
|
971
|
-
|
|
972
|
-
diagnostics.pushMany(document.diagnostics);
|
|
973
|
-
for (const entry of document.entries) {
|
|
974
|
-
const value = {
|
|
975
|
-
key: entry.key,
|
|
976
|
-
language: entry.language,
|
|
977
|
-
value: entry.value,
|
|
978
|
-
sourcePath: file.displayPath,
|
|
979
|
-
location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
|
|
980
|
-
};
|
|
981
|
-
addDomainEntry(localisation, value, 'localisation');
|
|
982
|
-
const id = deterministicId('gui_loc', value);
|
|
983
|
-
addNode(nodes, {
|
|
984
|
-
id,
|
|
985
|
-
kind: 'localisation',
|
|
986
|
-
name: entry.key,
|
|
987
|
-
path: file.displayPath,
|
|
988
|
-
...(value.location === undefined ? {} : { location: value.location }),
|
|
989
|
-
metadata: { language: entry.language, value: entry.value },
|
|
990
|
-
});
|
|
991
|
-
addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
|
|
992
|
-
}
|
|
994
|
+
localisationFiles.push({ file, fileNodeId });
|
|
993
995
|
continue;
|
|
994
996
|
}
|
|
995
997
|
if (kind === 'animation_source_manifest' && fileNodeId !== undefined) {
|
|
@@ -999,13 +1001,15 @@ export function buildGuiSourceGraph(files, sharedIndex) {
|
|
|
999
1001
|
if (kind === undefined || fileNodeId === undefined) {
|
|
1000
1002
|
if (/\.(?:txt|gui|gfx)$/u.test(normalized)) {
|
|
1001
1003
|
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
1002
|
-
|
|
1004
|
+
if (file.rootKind === 'mod' || file.rootKind === 'fixture')
|
|
1005
|
+
diagnostics.pushMany(document.diagnostics);
|
|
1003
1006
|
indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
|
|
1004
1007
|
}
|
|
1005
1008
|
continue;
|
|
1006
1009
|
}
|
|
1007
1010
|
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
1008
|
-
|
|
1011
|
+
if (file.rootKind === 'mod' || file.rootKind === 'fixture')
|
|
1012
|
+
diagnostics.pushMany(document.diagnostics);
|
|
1009
1013
|
if (kind === 'gui_file')
|
|
1010
1014
|
indexGuiElements(document, file, fileNodeId, nodes, edges, elements);
|
|
1011
1015
|
if (kind === 'gfx_file') {
|
|
@@ -1019,6 +1023,35 @@ export function buildGuiSourceGraph(files, sharedIndex) {
|
|
|
1019
1023
|
}
|
|
1020
1024
|
indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
|
|
1021
1025
|
}
|
|
1026
|
+
const referencedLocalisationKeys = referencedGuiLocalisationKeys(elements, scriptedLocalisation);
|
|
1027
|
+
for (const { file, fileNodeId } of localisationFiles.sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
|
|
1028
|
+
compareCodeUnits(left.file.displayPath, right.file.displayPath))) {
|
|
1029
|
+
const document = parseLocalisation(file.bytes, file.displayPath);
|
|
1030
|
+
if (file.rootKind === 'mod' || file.rootKind === 'fixture')
|
|
1031
|
+
diagnostics.pushMany(document.diagnostics);
|
|
1032
|
+
for (const entry of document.entries) {
|
|
1033
|
+
if (!referencedLocalisationKeys.has(entry.key))
|
|
1034
|
+
continue;
|
|
1035
|
+
const value = {
|
|
1036
|
+
key: entry.key,
|
|
1037
|
+
language: entry.language,
|
|
1038
|
+
value: entry.value,
|
|
1039
|
+
sourcePath: file.displayPath,
|
|
1040
|
+
location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
|
|
1041
|
+
};
|
|
1042
|
+
addDomainEntry(localisation, value, 'localisation');
|
|
1043
|
+
const id = deterministicId('gui_loc', value);
|
|
1044
|
+
addNode(nodes, {
|
|
1045
|
+
id,
|
|
1046
|
+
kind: 'localisation',
|
|
1047
|
+
name: entry.key,
|
|
1048
|
+
path: file.displayPath,
|
|
1049
|
+
...(value.location === undefined ? {} : { location: value.location }),
|
|
1050
|
+
metadata: { language: entry.language, value: entry.value },
|
|
1051
|
+
});
|
|
1052
|
+
addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1022
1055
|
linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scriptedLocalisation, localisation, textureNodes, fontAssetNodes, pendingDecisionEdges);
|
|
1023
1056
|
linkAnimationSources(nodes, edges, animationSources, sprites, textureNodes, animationFrameNodes);
|
|
1024
1057
|
markPartialReferenceEdges(edges, sharedIndex);
|
|
@@ -125,7 +125,18 @@ function under(roots, suffixes) {
|
|
|
125
125
|
return suffixes.map((suffix) => `${normalized}/${suffix}`);
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
|
-
function guiDefinitionPatterns(workspace
|
|
128
|
+
function guiDefinitionPatterns(workspace) {
|
|
129
|
+
const roots = workspace.registration.roots;
|
|
130
|
+
return [
|
|
131
|
+
...new Set([
|
|
132
|
+
...under(roots.interface, ['**/*.gui', '**/*.gfx']),
|
|
133
|
+
...under(roots.gfx, ['**/*.gfx']),
|
|
134
|
+
...under(roots.scriptedGui, ['**/*.txt']),
|
|
135
|
+
...staticGuiDefinitionPatterns,
|
|
136
|
+
]),
|
|
137
|
+
].sort((left, right) => compareCodeUnits(left, right));
|
|
138
|
+
}
|
|
139
|
+
function guiLocalisationPatterns(workspace, languages = ['l_english']) {
|
|
129
140
|
const roots = workspace.registration.roots;
|
|
130
141
|
const languageDirectories = [...new Set(languages)].map((language) => {
|
|
131
142
|
if (!/^l_[a-z_]+$/u.test(language)) {
|
|
@@ -135,9 +146,6 @@ function guiDefinitionPatterns(workspace, languages = ['l_english']) {
|
|
|
135
146
|
});
|
|
136
147
|
return [
|
|
137
148
|
...new Set([
|
|
138
|
-
...under(roots.interface, ['**/*.gui', '**/*.gfx']),
|
|
139
|
-
...under(roots.gfx, ['**/*.gfx']),
|
|
140
|
-
...under(roots.scriptedGui, ['**/*.txt']),
|
|
141
149
|
...under(roots.localisation, [
|
|
142
150
|
'*.{yml,yaml}',
|
|
143
151
|
...languageDirectories.flatMap((directory) => [
|
|
@@ -145,7 +153,6 @@ function guiDefinitionPatterns(workspace, languages = ['l_english']) {
|
|
|
145
153
|
`${directory}/**/*.yaml`,
|
|
146
154
|
]),
|
|
147
155
|
]),
|
|
148
|
-
...staticGuiDefinitionPatterns,
|
|
149
156
|
]),
|
|
150
157
|
].sort((left, right) => compareCodeUnits(left, right));
|
|
151
158
|
}
|
|
@@ -395,11 +402,18 @@ export class ScriptedGuiStudio {
|
|
|
395
402
|
async scan(workspaceId, principal, signal, languages = ['l_english']) {
|
|
396
403
|
signal?.throwIfAborted();
|
|
397
404
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
398
|
-
const snapshot = await
|
|
405
|
+
const [snapshot, localisationFiles] = await Promise.all([
|
|
406
|
+
this.engine.scan(workspaceId, { patterns: guiDefinitionPatterns(workspace) }, principal, signal),
|
|
407
|
+
this.scanner.scan(workspace, {
|
|
408
|
+
patterns: guiLocalisationPatterns(workspace, languages),
|
|
409
|
+
...(signal === undefined ? {} : { signal }),
|
|
410
|
+
}),
|
|
411
|
+
]);
|
|
399
412
|
signal?.throwIfAborted();
|
|
413
|
+
const files = mergeScannedFiles(snapshot.files, localisationFiles);
|
|
400
414
|
return {
|
|
401
|
-
files
|
|
402
|
-
graph: buildGuiSourceGraph(
|
|
415
|
+
files,
|
|
416
|
+
graph: buildGuiSourceGraph(files, snapshot.index),
|
|
403
417
|
};
|
|
404
418
|
}
|
|
405
419
|
async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english']) {
|
|
@@ -599,6 +613,7 @@ export class ScriptedGuiStudio {
|
|
|
599
613
|
const stored = await this.artifacts.withAtomicChunkedWrites(workspace, writes, (artifacts) => Promise.resolve([...artifacts]), input.signal);
|
|
600
614
|
return {
|
|
601
615
|
artifacts: stored,
|
|
616
|
+
filesScanned: scanned.graph.filesScanned,
|
|
602
617
|
render,
|
|
603
618
|
stateScenes,
|
|
604
619
|
resolutionScenes,
|
|
@@ -1,18 +1,99 @@
|
|
|
1
1
|
export const MAP_DIAGNOSTIC_LIMIT = 2_000;
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
function isHardDiagnostic(diagnostic) {
|
|
3
|
+
return diagnostic.severity === 'error' || diagnostic.severity === 'blocker';
|
|
4
|
+
}
|
|
5
|
+
function isWorkspacePath(path) {
|
|
6
|
+
return path.startsWith('mod:') || path.startsWith('fixture:');
|
|
7
|
+
}
|
|
8
|
+
function isBaselineOnlyDiagnostic(diagnostic) {
|
|
9
|
+
if (diagnostic.location === undefined || isWorkspacePath(diagnostic.location.path))
|
|
10
|
+
return false;
|
|
11
|
+
return !(diagnostic.related ?? []).some(({ path }) => isWorkspacePath(path));
|
|
12
|
+
}
|
|
13
|
+
function addOmittedDiagnostic(details, diagnostic) {
|
|
14
|
+
details.omitted += 1;
|
|
15
|
+
details.omittedBySeverity[diagnostic.severity] =
|
|
16
|
+
(details.omittedBySeverity[diagnostic.severity] ?? 0) + 1;
|
|
17
|
+
details.omittedByCode[diagnostic.code] = (details.omittedByCode[diagnostic.code] ?? 0) + 1;
|
|
18
|
+
}
|
|
19
|
+
function truncationDiagnostic(omitted) {
|
|
20
|
+
const details = {
|
|
21
|
+
limit: MAP_DIAGNOSTIC_LIMIT,
|
|
22
|
+
retained: MAP_DIAGNOSTIC_LIMIT - 1,
|
|
23
|
+
omitted: 0,
|
|
24
|
+
omittedBySeverity: {},
|
|
25
|
+
omittedByCode: {},
|
|
26
|
+
};
|
|
27
|
+
for (const diagnostic of omitted)
|
|
28
|
+
addOmittedDiagnostic(details, diagnostic);
|
|
29
|
+
const hardOmitted = omitted.some(isHardDiagnostic);
|
|
30
|
+
return {
|
|
31
|
+
code: 'MAP_DIAGNOSTICS_TRUNCATED',
|
|
32
|
+
severity: hardOmitted ? 'blocker' : 'info',
|
|
33
|
+
category: 'map',
|
|
34
|
+
message: hardOmitted
|
|
35
|
+
? 'Map diagnostic detail exceeded the result ceiling and omitted an error or blocker'
|
|
36
|
+
: 'Map diagnostic detail exceeded the result ceiling; deterministic samples and omission counts were retained',
|
|
37
|
+
details,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function existingTruncationDiagnostic(diagnostics) {
|
|
41
|
+
const marker = diagnostics.at(-1);
|
|
42
|
+
return marker?.code === 'MAP_DIAGNOSTICS_TRUNCATED'
|
|
43
|
+
? marker
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
function addBoundedDiagnostic(diagnostics, diagnostic, includeBaseline) {
|
|
47
|
+
if (!includeBaseline && isBaselineOnlyDiagnostic(diagnostic))
|
|
48
|
+
return;
|
|
49
|
+
const marker = existingTruncationDiagnostic(diagnostics);
|
|
50
|
+
if (marker !== undefined) {
|
|
51
|
+
if (isHardDiagnostic(diagnostic)) {
|
|
52
|
+
const replaceIndex = diagnostics
|
|
53
|
+
.slice(0, -1)
|
|
54
|
+
.findLastIndex((retained) => !isHardDiagnostic(retained));
|
|
55
|
+
if (replaceIndex >= 0) {
|
|
56
|
+
const replaced = diagnostics[replaceIndex];
|
|
57
|
+
diagnostics[replaceIndex] = diagnostic;
|
|
58
|
+
addOmittedDiagnostic(marker.details, replaced);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
marker.severity = 'blocker';
|
|
62
|
+
marker.message =
|
|
63
|
+
'Map diagnostic detail exceeded the result ceiling and omitted an error or blocker';
|
|
64
|
+
}
|
|
65
|
+
addOmittedDiagnostic(marker.details, diagnostic);
|
|
4
66
|
return;
|
|
67
|
+
}
|
|
5
68
|
if (diagnostics.length < MAP_DIAGNOSTIC_LIMIT) {
|
|
6
69
|
diagnostics.push(diagnostic);
|
|
7
70
|
return;
|
|
8
71
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
72
|
+
const candidates = [...diagnostics, diagnostic];
|
|
73
|
+
const hard = candidates.filter(isHardDiagnostic);
|
|
74
|
+
const soft = candidates.filter((candidate) => !isHardDiagnostic(candidate));
|
|
75
|
+
const retained = hard.length >= MAP_DIAGNOSTIC_LIMIT - 1
|
|
76
|
+
? hard.slice(0, MAP_DIAGNOSTIC_LIMIT - 1)
|
|
77
|
+
: [...hard, ...soft.slice(0, MAP_DIAGNOSTIC_LIMIT - 1 - hard.length)];
|
|
78
|
+
const retainedCounts = new Map();
|
|
79
|
+
for (const item of retained)
|
|
80
|
+
retainedCounts.set(item, (retainedCounts.get(item) ?? 0) + 1);
|
|
81
|
+
const omitted = candidates.filter((candidate) => {
|
|
82
|
+
const count = retainedCounts.get(candidate) ?? 0;
|
|
83
|
+
if (count === 0)
|
|
84
|
+
return true;
|
|
85
|
+
retainedCounts.set(candidate, count - 1);
|
|
86
|
+
return false;
|
|
87
|
+
});
|
|
88
|
+
diagnostics.splice(0, diagnostics.length, ...retained, truncationDiagnostic(omitted));
|
|
89
|
+
}
|
|
90
|
+
export function addMapDiagnostic(diagnostics, diagnostic) {
|
|
91
|
+
addBoundedDiagnostic(diagnostics, diagnostic, false);
|
|
92
|
+
}
|
|
93
|
+
/** Active topology validation must report failures in whichever source wins
|
|
94
|
+
* precedence, including inherited game and dependency files. */
|
|
95
|
+
export function addActiveMapDiagnostic(diagnostics, diagnostic) {
|
|
96
|
+
addBoundedDiagnostic(diagnostics, diagnostic, true);
|
|
16
97
|
}
|
|
17
98
|
export function addMapDiagnostics(diagnostics, additions) {
|
|
18
99
|
for (const diagnostic of additions)
|
|
@@ -24,11 +24,13 @@ const MAP_TEXT_FIELD_LIMIT = 10_000;
|
|
|
24
24
|
const MAP_MODEL_MAX_SCRIPT_FILES = 5_000;
|
|
25
25
|
const MAP_MODEL_MAX_SOURCE_TOKENS = 1_000_000;
|
|
26
26
|
const MAP_MODEL_MAX_RECORDS = 500_000;
|
|
27
|
+
const MAP_MODEL_MAX_LOCALISATION_RECORDS = 2_000_000;
|
|
27
28
|
const MAP_MODEL_MAX_MEMBERSHIP_EDGES = 1_000_000;
|
|
28
29
|
class MapModelBudget {
|
|
29
30
|
#documents = new Set();
|
|
30
31
|
#tokens = 0;
|
|
31
32
|
#records = 0;
|
|
33
|
+
#localisationRecords = 0;
|
|
32
34
|
#membershipEdges = 0;
|
|
33
35
|
assertScriptFiles(count) {
|
|
34
36
|
if (count > MAP_MODEL_MAX_SCRIPT_FILES) {
|
|
@@ -50,6 +52,13 @@ class MapModelBudget {
|
|
|
50
52
|
this.block('domain records', this.#records, MAP_MODEL_MAX_RECORDS);
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
addLocalisationRecords(count) {
|
|
56
|
+
this.#localisationRecords += count;
|
|
57
|
+
if (!Number.isSafeInteger(this.#localisationRecords) ||
|
|
58
|
+
this.#localisationRecords > MAP_MODEL_MAX_LOCALISATION_RECORDS) {
|
|
59
|
+
this.block('localisation records', this.#localisationRecords, MAP_MODEL_MAX_LOCALISATION_RECORDS);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
53
62
|
addMembershipEdges(count) {
|
|
54
63
|
this.#membershipEdges += count;
|
|
55
64
|
if (!Number.isSafeInteger(this.#membershipEdges) ||
|
|
@@ -869,7 +878,9 @@ export class MapWorkspaceIndex {
|
|
|
869
878
|
const modelBudget = new MapModelBudget();
|
|
870
879
|
modelBudget.assertScriptFiles(this.sourceFiles.filter((file) => inConfiguredRoot(file.relativePath, this.sourceRoots.states) ||
|
|
871
880
|
this.sourceRoots.map.some((root) => normalizedPath(file.relativePath).startsWith(`${root}/strategicregions/`)) ||
|
|
872
|
-
|
|
881
|
+
((file.rootKind === 'mod' || file.rootKind === 'fixture') &&
|
|
882
|
+
normalizedPath(file.relativePath).endsWith('.asset') &&
|
|
883
|
+
file.bytes.includes('locator'))).length);
|
|
873
884
|
this.defaultMapFile = fileInRoots(this.activeFiles, this.sourceRoots.map, 'default.map');
|
|
874
885
|
const definitionsName = defaultMapSelectorValue(this.defaultMapFile, 'definitions', 'definition.csv');
|
|
875
886
|
const provincesName = defaultMapSelectorValue(this.defaultMapFile, 'provinces', 'provinces.bmp');
|
|
@@ -1031,7 +1042,9 @@ export class MapWorkspaceIndex {
|
|
|
1031
1042
|
weatherFile === undefined ? [] : parseWeatherPositions(weatherFile, this.diagnostics);
|
|
1032
1043
|
modelBudget.addRecords(this.weatherPositions.length);
|
|
1033
1044
|
this.entityLocators = this.activeFiles.all
|
|
1034
|
-
.filter((file) =>
|
|
1045
|
+
.filter((file) => (file.rootKind === 'mod' || file.rootKind === 'fixture') &&
|
|
1046
|
+
normalizedPath(file.relativePath).endsWith('.asset') &&
|
|
1047
|
+
file.bytes.includes('locator'))
|
|
1035
1048
|
.flatMap((file) => {
|
|
1036
1049
|
const locators = parseEntityLocators(file, this.diagnostics);
|
|
1037
1050
|
if (locators[0] !== undefined)
|
|
@@ -1042,8 +1055,13 @@ export class MapWorkspaceIndex {
|
|
|
1042
1055
|
for (const file of this.activeFiles.all.filter((entry) => inConfiguredRoot(entry.relativePath, this.sourceRoots.localisation) &&
|
|
1043
1056
|
normalizedPath(entry.relativePath).endsWith('.yml'))) {
|
|
1044
1057
|
const document = parseLocalisation(file.bytes, file.displayPath);
|
|
1045
|
-
|
|
1046
|
-
|
|
1058
|
+
if (file.rootKind === 'mod' || file.rootKind === 'fixture') {
|
|
1059
|
+
addMapDiagnostics(this.diagnostics, document.diagnostics);
|
|
1060
|
+
}
|
|
1061
|
+
// Localisation is a supporting lookup table rather than connected map topology. Account for
|
|
1062
|
+
// it separately so a normal full-game language database cannot exhaust the domain-record
|
|
1063
|
+
// budget before provinces, states, regions, railways, and adjacencies are available.
|
|
1064
|
+
modelBudget.addLocalisationRecords(document.entries.length);
|
|
1047
1065
|
for (const entry of document.entries) {
|
|
1048
1066
|
this.localisationKeys.add(`${entry.language}:${entry.key}`);
|
|
1049
1067
|
const indexed = { file, document, entry };
|
|
@@ -4,6 +4,7 @@ import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '.
|
|
|
4
4
|
import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
|
|
5
5
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
6
6
|
import { CoreEngine } from '../core/engine.js';
|
|
7
|
+
import { SymbolIndex } from '../core/index.js';
|
|
7
8
|
import { RenderBudget } from '../core/render-budget.js';
|
|
8
9
|
import { ServiceError } from '../core/result.js';
|
|
9
10
|
import { WorkspaceScanner } from '../core/scanner.js';
|
|
@@ -386,7 +387,6 @@ function sourceTextPatterns(roots) {
|
|
|
386
387
|
const normalized = normalizeSourceRoot(root);
|
|
387
388
|
return [`${normalized}/english/**/*.{yml,yaml}`, `${normalized}/*.{yml,yaml}`];
|
|
388
389
|
}),
|
|
389
|
-
'**/*.asset',
|
|
390
390
|
];
|
|
391
391
|
}
|
|
392
392
|
function provinceBitmapPatterns(defaultMaps, mapRoots) {
|
|
@@ -491,14 +491,15 @@ export class AgentNudger {
|
|
|
491
491
|
const selectorSnapshot = await this.engine.scan(workspaceId, { patterns: defaultMapPatterns(sourceRoots) }, principal, signal);
|
|
492
492
|
const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map);
|
|
493
493
|
const contentSnapshot = await this.engine.scan(workspaceId, { patterns: [...sourceTextPatterns(sourceRoots), ...bitmapPatterns] }, principal, signal);
|
|
494
|
+
const locatorSnapshot = await this.engine.scan(workspaceId, { patterns: ['**/*.asset'], rootKinds: ['mod', 'fixture'] }, principal, signal);
|
|
494
495
|
signal?.throwIfAborted();
|
|
495
|
-
const files = contentSnapshot.files;
|
|
496
|
+
const files = [...contentSnapshot.files, ...locatorSnapshot.files].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
496
497
|
const revision = hashCanonical(files.map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
|
|
497
498
|
return {
|
|
498
499
|
workspaceId,
|
|
499
500
|
revision,
|
|
500
501
|
files,
|
|
501
|
-
index: MapWorkspaceIndex.build(files, sourceRoots,
|
|
502
|
+
index: MapWorkspaceIndex.build(files, sourceRoots, SymbolIndex.build(files)),
|
|
502
503
|
};
|
|
503
504
|
}
|
|
504
505
|
async validate(workspaceId, principal, signal) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
2
2
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
3
3
|
import { assertRenderDimensions } from '../core/render-budget.js';
|
|
4
|
-
import { addMapDiagnostic
|
|
4
|
+
import { addActiveMapDiagnostic, addMapDiagnostic } from './diagnostic-limit.js';
|
|
5
5
|
import { rgbKey } from './bmp.js';
|
|
6
6
|
import { MAP_PROVINCE_ENGINE_MAX_PIXELS } from './model.js';
|
|
7
7
|
const MAP_DIAGNOSTIC_DETAIL_SAMPLE_LIMIT = 100;
|
|
@@ -53,7 +53,8 @@ function definitionLocation(definition) {
|
|
|
53
53
|
return requiredLineLocation(definition.document, definition.line);
|
|
54
54
|
}
|
|
55
55
|
function addDiagnostic(diagnostics, options, diagnostic) {
|
|
56
|
-
|
|
56
|
+
const add = options.includeBaselineDiagnostics === true ? addActiveMapDiagnostic : addMapDiagnostic;
|
|
57
|
+
add(diagnostics, {
|
|
57
58
|
...diagnostic,
|
|
58
59
|
...(diagnostic.operationId !== undefined || options.operationId === undefined
|
|
59
60
|
? {}
|
|
@@ -5,7 +5,7 @@ import { registerGuiTools } from '../tools/gui.js';
|
|
|
5
5
|
import { registerMapTools } from '../tools/map.js';
|
|
6
6
|
import { registerEventTools } from '../tools/event.js';
|
|
7
7
|
import { registerMcpResources } from '../resources/register.js';
|
|
8
|
-
export const SERVER_INSTRUCTIONS = '
|
|
8
|
+
export const SERVER_INSTRUCTIONS = 'Use hoi4.focus_inspect and hoi4.focus_render for fast structural work; hoi4.focus_raster adds decoded icons and PNG output. Use hoi4.event_inspect to analyze event chains. Event tools are read-only. Focus, GUI, and map rewrite tools edit the current mod. Large evidence is returned through linked resources.';
|
|
9
9
|
export function createMcpServer(engine, context = {}) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: PACKAGE_NAME,
|