hoi4-agent-tools 1.2.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 +21 -1
- package/README.md +6 -7
- 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/core/workspace.js +31 -3
- 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 +126 -27
- package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
- 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 -3
- package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/event.js +6 -6
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +149 -224
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +35 -25
- package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
- package/dist/hoi4_agent_tools/runtime.js +112 -2
- package/dist/hoi4_agent_tools/schemas/common.js +5 -1
- 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 +5 -1
- package/docs/map.md +1 -1
- package/docs/setup.md +9 -6
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { promisify } from 'node:util';
|
|
2
|
+
import { gzip } from 'node:zlib';
|
|
3
|
+
const gzipAsync = promisify(gzip);
|
|
4
|
+
export const GUI_INSPECTION_COMPRESSION_THRESHOLD = 134_217_728;
|
|
5
|
+
export const GUI_INSPECTION_FULL_GRAPH_NODE_LIMIT = 50_000;
|
|
6
|
+
function isWorkspaceSource(path) {
|
|
7
|
+
return path.startsWith('mod:') || path.startsWith('fixture:');
|
|
8
|
+
}
|
|
9
|
+
const workspaceSeedKinds = new Set([
|
|
10
|
+
'gui_file',
|
|
11
|
+
'gfx_file',
|
|
12
|
+
'scripted_gui_file',
|
|
13
|
+
'scripted_localisation_file',
|
|
14
|
+
'gui_element',
|
|
15
|
+
'sprite',
|
|
16
|
+
'texture',
|
|
17
|
+
'font',
|
|
18
|
+
'scripted_gui',
|
|
19
|
+
'scripted_localisation',
|
|
20
|
+
'animation_source_manifest',
|
|
21
|
+
'animation_source_frame',
|
|
22
|
+
]);
|
|
23
|
+
function isWorkspaceGraphSeed(node) {
|
|
24
|
+
return isWorkspaceSource(node.path) && workspaceSeedKinds.has(node.kind);
|
|
25
|
+
}
|
|
26
|
+
function graphCounts(graph) {
|
|
27
|
+
return {
|
|
28
|
+
nodes: graph.nodes.length,
|
|
29
|
+
edges: graph.edges.length,
|
|
30
|
+
elements: graph.elements.length,
|
|
31
|
+
sprites: graph.sprites.length,
|
|
32
|
+
fonts: graph.fonts.length,
|
|
33
|
+
scriptedGuis: graph.scriptedGuis.length,
|
|
34
|
+
animationSources: graph.animationSources.length,
|
|
35
|
+
scriptedLocalisation: graph.scriptedLocalisation.length,
|
|
36
|
+
localisation: graph.localisation.length,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A complete vanilla GUI inventory is useful for linking but wasteful to repeat in every
|
|
41
|
+
* artifact. Large reports retain the workspace overlay, the selected scene, and every
|
|
42
|
+
* directly connected node while recording exact full-inventory counts.
|
|
43
|
+
*/
|
|
44
|
+
export function projectGuiGraphForArtifact(graph, selectedSourceIds = [], fullGraphNodeLimit = GUI_INSPECTION_FULL_GRAPH_NODE_LIMIT) {
|
|
45
|
+
if (graph.nodes.length <= fullGraphNodeLimit)
|
|
46
|
+
return { graph };
|
|
47
|
+
const retainedIds = new Set(selectedSourceIds);
|
|
48
|
+
for (const node of graph.nodes)
|
|
49
|
+
if (isWorkspaceGraphSeed(node))
|
|
50
|
+
retainedIds.add(node.id);
|
|
51
|
+
const retainedEdges = graph.edges.filter(({ from, to }) => retainedIds.has(from) || retainedIds.has(to));
|
|
52
|
+
for (const edge of retainedEdges) {
|
|
53
|
+
retainedIds.add(edge.from);
|
|
54
|
+
retainedIds.add(edge.to);
|
|
55
|
+
}
|
|
56
|
+
const retainedLocalisation = new Set(graph.nodes
|
|
57
|
+
.filter(({ id, kind }) => kind === 'localisation' && retainedIds.has(id))
|
|
58
|
+
.map(({ name, path }) => `${path}\u0000${name}`));
|
|
59
|
+
const retainedGraph = {
|
|
60
|
+
...graph,
|
|
61
|
+
nodes: graph.nodes.filter(({ id }) => retainedIds.has(id)),
|
|
62
|
+
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)),
|
|
69
|
+
localisation: graph.localisation.filter(({ key, sourcePath }) => retainedLocalisation.has(`${sourcePath}\u0000${key}`)),
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
graph: retainedGraph,
|
|
73
|
+
projection: {
|
|
74
|
+
mode: 'workspace-overlay-and-selected',
|
|
75
|
+
fullCounts: graphCounts(graph),
|
|
76
|
+
returnedCounts: graphCounts(retainedGraph),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Keeps small reports directly readable and compresses very large source graphs deterministically. */
|
|
81
|
+
export async function encodeGuiInspectionArtifact(name, json, compressionThreshold = GUI_INSPECTION_COMPRESSION_THRESHOLD) {
|
|
82
|
+
const uncompressedBytes = Buffer.byteLength(json);
|
|
83
|
+
if (uncompressedBytes <= compressionThreshold) {
|
|
84
|
+
return {
|
|
85
|
+
name,
|
|
86
|
+
mimeType: 'application/json',
|
|
87
|
+
content: json,
|
|
88
|
+
compressed: false,
|
|
89
|
+
uncompressedBytes,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
name: `${name}.gz`,
|
|
94
|
+
mimeType: 'application/gzip',
|
|
95
|
+
content: await gzipAsync(Buffer.from(json), { level: 1 }),
|
|
96
|
+
compressed: true,
|
|
97
|
+
uncompressedBytes,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -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',
|
|
@@ -40,8 +42,55 @@ function scalarString(value) {
|
|
|
40
42
|
return String(value);
|
|
41
43
|
return undefined;
|
|
42
44
|
}
|
|
45
|
+
const defaultTextColour = '#f5f2e8';
|
|
46
|
+
const hoi4TextColours = {
|
|
47
|
+
B: '#4aa3df',
|
|
48
|
+
C: '#55d6d6',
|
|
49
|
+
G: '#56b870',
|
|
50
|
+
H: '#e7b454',
|
|
51
|
+
L: '#9aa7b3',
|
|
52
|
+
M: '#d16bd4',
|
|
53
|
+
O: '#e58b3c',
|
|
54
|
+
R: '#e05a5a',
|
|
55
|
+
T: '#4bc6b9',
|
|
56
|
+
W: defaultTextColour,
|
|
57
|
+
Y: '#f1c75b',
|
|
58
|
+
b: '#4aa3df',
|
|
59
|
+
g: '#87939e',
|
|
60
|
+
};
|
|
43
61
|
function visibleHoiText(value) {
|
|
44
|
-
|
|
62
|
+
const characters = [];
|
|
63
|
+
const colours = [];
|
|
64
|
+
let colour;
|
|
65
|
+
let hasColourMarkup = false;
|
|
66
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
67
|
+
const character = value[index] ?? '';
|
|
68
|
+
const next = value[index + 1];
|
|
69
|
+
if (character === '\u00a7' && next !== undefined && /^[A-Za-z0-9!]$/u.test(next)) {
|
|
70
|
+
hasColourMarkup = true;
|
|
71
|
+
colour = next === '!' ? undefined : hoi4TextColours[next];
|
|
72
|
+
index += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (character === '\u00a3') {
|
|
76
|
+
let cursor = index + 1;
|
|
77
|
+
while (cursor < value.length && !/[\s\u00a3]/u.test(value[cursor] ?? ''))
|
|
78
|
+
cursor += 1;
|
|
79
|
+
characters.push('\u25c6');
|
|
80
|
+
colours.push(colour);
|
|
81
|
+
index = cursor - 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (character === '\\' && next === 'n') {
|
|
85
|
+
characters.push('\n');
|
|
86
|
+
colours.push(colour);
|
|
87
|
+
index += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
characters.push(character);
|
|
91
|
+
colours.push(colour);
|
|
92
|
+
}
|
|
93
|
+
return { text: characters.join(''), colours, hasColourMarkup };
|
|
45
94
|
}
|
|
46
95
|
function scalarNumber(value, reference) {
|
|
47
96
|
if (typeof value === 'number')
|
|
@@ -167,13 +216,17 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
|
|
|
167
216
|
const key = match[1] ?? '';
|
|
168
217
|
const replacement = rowValues?.[key] ?? scenario.variables[key] ?? scenario.scriptedGui[key];
|
|
169
218
|
if (replacement === undefined) {
|
|
170
|
-
unresolved.push(
|
|
171
|
-
return
|
|
219
|
+
unresolved.push(match[0]);
|
|
220
|
+
return /(?:^|\.)Get[A-Za-z0-9_]+$/u.test(key)
|
|
221
|
+
? textDynamicPlaceholder
|
|
222
|
+
: numericDynamicPlaceholder;
|
|
172
223
|
}
|
|
173
224
|
return String(replacement);
|
|
174
225
|
}, 'GUI variable substitution');
|
|
175
226
|
text = replaceTextBounded(text, /\[([A-Za-z0-9_.:-]+)\]/gu, (match) => {
|
|
176
227
|
const key = match[1] ?? '';
|
|
228
|
+
if (key === 'X' || key === 'dynamic_loc')
|
|
229
|
+
return match[0];
|
|
177
230
|
const countryKey = key.replace(/^(?:ROOT|This)\./u, '');
|
|
178
231
|
const replacement = rowValues?.[countryKey] ??
|
|
179
232
|
scenario.country?.[countryKey] ??
|
|
@@ -181,16 +234,16 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
|
|
|
181
234
|
scenario.scriptedGui[key];
|
|
182
235
|
if (replacement === undefined) {
|
|
183
236
|
unresolved.push(match[0]);
|
|
184
|
-
return
|
|
237
|
+
return textDynamicPlaceholder;
|
|
185
238
|
}
|
|
186
239
|
return String(replacement);
|
|
187
240
|
}, 'GUI scope substitution');
|
|
188
241
|
return { text, unresolved: [...new Set(unresolved)].sort(), missingLocalisation };
|
|
189
242
|
}
|
|
190
|
-
function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
191
|
-
const paragraphs = text.replaceAll('\\n', '\n').split('\n');
|
|
243
|
+
function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work) {
|
|
192
244
|
const lines = [];
|
|
193
245
|
const widths = [];
|
|
246
|
+
const colourLines = [];
|
|
194
247
|
const missingGlyphs = new Set();
|
|
195
248
|
const retainMissingGlyphs = (values) => {
|
|
196
249
|
for (const value of values) {
|
|
@@ -201,12 +254,44 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
|
201
254
|
};
|
|
202
255
|
let lineHeight = fontSize * 1.2;
|
|
203
256
|
let metricSource = 'approximation';
|
|
204
|
-
|
|
257
|
+
const commitLine = (words) => {
|
|
258
|
+
const characters = [];
|
|
259
|
+
const lineColours = [];
|
|
260
|
+
for (const [index, word] of words.entries()) {
|
|
261
|
+
if (index > 0) {
|
|
262
|
+
characters.push(' ');
|
|
263
|
+
lineColours.push(lineColours.at(-1) ?? word.colours[0]);
|
|
264
|
+
}
|
|
265
|
+
characters.push(word.text);
|
|
266
|
+
lineColours.push(...word.colours);
|
|
267
|
+
}
|
|
268
|
+
const committedText = characters.join('');
|
|
269
|
+
work.spendTextLayout('committed text line measurement');
|
|
270
|
+
const committed = catalog.measureText(fontName, committedText, fontSize);
|
|
271
|
+
lines.push(committedText);
|
|
272
|
+
widths.push(committed.width);
|
|
273
|
+
colourLines.push(lineColours);
|
|
274
|
+
retainMissingGlyphs(committed.missingGlyphs);
|
|
275
|
+
lineHeight = committed.lineHeight;
|
|
276
|
+
metricSource = committed.source;
|
|
277
|
+
};
|
|
278
|
+
let paragraphStart = 0;
|
|
279
|
+
for (let paragraphEnd = 0; paragraphEnd <= text.length; paragraphEnd += 1) {
|
|
280
|
+
if (paragraphEnd < text.length && text[paragraphEnd] !== '\n')
|
|
281
|
+
continue;
|
|
205
282
|
work.spendTextLayout('text paragraph layout');
|
|
206
|
-
const
|
|
283
|
+
const paragraph = text.slice(paragraphStart, paragraphEnd);
|
|
284
|
+
const paragraphColours = colours.slice(paragraphStart, paragraphEnd);
|
|
285
|
+
const words = [...paragraph.matchAll(/\S+/gu)].map((match) => {
|
|
286
|
+
const start = match.index;
|
|
287
|
+
const word = match[0];
|
|
288
|
+
return { text: word, colours: paragraphColours.slice(start, start + word.length) };
|
|
289
|
+
});
|
|
290
|
+
paragraphStart = paragraphEnd + 1;
|
|
207
291
|
if (words.length === 0) {
|
|
208
292
|
lines.push('');
|
|
209
293
|
widths.push(0);
|
|
294
|
+
colourLines.push([]);
|
|
210
295
|
continue;
|
|
211
296
|
}
|
|
212
297
|
let currentWords = [];
|
|
@@ -215,22 +300,17 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
|
215
300
|
let previousWordWidth = 0;
|
|
216
301
|
for (const word of words) {
|
|
217
302
|
work.spendTextLayout('text word measurement');
|
|
218
|
-
const measuredWord = catalog.measureText(fontName, word, fontSize);
|
|
303
|
+
const measuredWord = catalog.measureText(fontName, word.text, fontSize);
|
|
219
304
|
retainMissingGlyphs(measuredWord.missingGlyphs);
|
|
220
305
|
let candidateWidth = measuredWord.width;
|
|
221
306
|
if (previousWord !== undefined) {
|
|
222
307
|
work.spendTextLayout('text word-boundary measurement');
|
|
223
|
-
const boundary = catalog.measureText(fontName, `${previousWord} ${word}`, fontSize);
|
|
308
|
+
const boundary = catalog.measureText(fontName, `${previousWord} ${word.text}`, fontSize);
|
|
224
309
|
retainMissingGlyphs(boundary.missingGlyphs);
|
|
225
310
|
candidateWidth = currentWidth + boundary.width - previousWordWidth;
|
|
226
311
|
}
|
|
227
312
|
if (maximumWidth > 0 && candidateWidth > maximumWidth && currentWords.length > 0) {
|
|
228
|
-
|
|
229
|
-
work.spendTextLayout('committed text line measurement');
|
|
230
|
-
const committed = catalog.measureText(fontName, committedText, fontSize);
|
|
231
|
-
lines.push(committedText);
|
|
232
|
-
widths.push(committed.width);
|
|
233
|
-
retainMissingGlyphs(committed.missingGlyphs);
|
|
313
|
+
commitLine(currentWords);
|
|
234
314
|
currentWords = [word];
|
|
235
315
|
currentWidth = measuredWord.width;
|
|
236
316
|
}
|
|
@@ -238,26 +318,40 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
|
238
318
|
currentWords.push(word);
|
|
239
319
|
currentWidth = candidateWidth;
|
|
240
320
|
}
|
|
241
|
-
previousWord = word;
|
|
321
|
+
previousWord = word.text;
|
|
242
322
|
previousWordWidth = measuredWord.width;
|
|
243
323
|
}
|
|
244
|
-
|
|
245
|
-
work.spendTextLayout('committed text line measurement');
|
|
246
|
-
const committed = catalog.measureText(fontName, committedText, fontSize);
|
|
247
|
-
lines.push(committedText);
|
|
248
|
-
widths.push(committed.width);
|
|
249
|
-
retainMissingGlyphs(committed.missingGlyphs);
|
|
250
|
-
lineHeight = committed.lineHeight;
|
|
251
|
-
metricSource = committed.source;
|
|
324
|
+
commitLine(currentWords);
|
|
252
325
|
}
|
|
253
326
|
return {
|
|
254
327
|
lines,
|
|
255
328
|
widths,
|
|
329
|
+
colourLines,
|
|
256
330
|
lineHeight,
|
|
257
331
|
metricSource,
|
|
258
332
|
missingGlyphs: [...missingGlyphs].sort((a, b) => a - b),
|
|
259
333
|
};
|
|
260
334
|
}
|
|
335
|
+
function colourRunsForLine(catalog, fontName, line, colours, fontSize, work) {
|
|
336
|
+
if (!colours.some((colour) => colour !== undefined) || line.length === 0)
|
|
337
|
+
return [];
|
|
338
|
+
const runs = [];
|
|
339
|
+
let start = 0;
|
|
340
|
+
let offsetX = 0;
|
|
341
|
+
while (start < line.length) {
|
|
342
|
+
const colour = colours[start] ?? defaultTextColour;
|
|
343
|
+
let end = start + 1;
|
|
344
|
+
while (end < line.length && (colours[end] ?? defaultTextColour) === colour)
|
|
345
|
+
end += 1;
|
|
346
|
+
const text = line.slice(start, end);
|
|
347
|
+
work.spendTextLayout('localisation colour-run measurement');
|
|
348
|
+
const width = catalog.measureText(fontName, text, fontSize).width;
|
|
349
|
+
runs.push({ text, colour, offsetX, width });
|
|
350
|
+
offsetX += width;
|
|
351
|
+
start = end;
|
|
352
|
+
}
|
|
353
|
+
return runs;
|
|
354
|
+
}
|
|
261
355
|
function frameFor(element, sprite, scenario) {
|
|
262
356
|
const frameCount = Math.max(1, sprite.frameCount);
|
|
263
357
|
const selected = scenario.selectedFrames[element.name] ?? scenario.selectedFrames[sprite.name];
|
|
@@ -439,7 +533,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
439
533
|
displayText = `${displayText} — ${displayText} — ${displayText}`;
|
|
440
534
|
if (state === 'missing-localisation')
|
|
441
535
|
displayText = `\u00a7R${rawText}_MISSING\u00a7!`;
|
|
442
|
-
|
|
536
|
+
const visibleText = visibleHoiText(displayText);
|
|
537
|
+
displayText = visibleText.text;
|
|
443
538
|
context.work.admitText(displayText, `GUI text for ${definition.name}`);
|
|
444
539
|
const fontName = scalarString(property(definition.attributes, 'font', 'buttonFont'));
|
|
445
540
|
const resolvedFontMetrics = catalog.resolvedFontMetrics(fontName);
|
|
@@ -450,10 +545,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
450
545
|
16;
|
|
451
546
|
const maxWidth = (scalarNumber(property(definition.attributes, 'maxWidth'), width / scale) ?? width / scale) *
|
|
452
547
|
scale;
|
|
453
|
-
const wrapped = wrapText(catalog, fontName, displayText, fontSize * scale, maxWidth, context.work);
|
|
548
|
+
const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work);
|
|
454
549
|
const glyphLines = [];
|
|
455
550
|
for (const line of wrapped.lines)
|
|
456
551
|
glyphLines.push(await catalog.shapeText(fontName, line, fontSize * scale));
|
|
552
|
+
const colourRuns = visibleText.hasColourMarkup
|
|
553
|
+
? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work))
|
|
554
|
+
: undefined;
|
|
457
555
|
const maximumLineWidth = wrapped.widths.reduce((maximum, value) => Math.max(maximum, value), 0);
|
|
458
556
|
if (width === 0)
|
|
459
557
|
width = maximumLineWidth;
|
|
@@ -476,6 +574,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
476
574
|
overflowX: width > 0 && measuredWidth > width + 0.01,
|
|
477
575
|
overflowY: height > 0 && measuredHeight > height + 0.01,
|
|
478
576
|
unresolvedTokens: resolved.unresolved,
|
|
577
|
+
...(colourRuns === undefined ? {} : { colourRuns }),
|
|
479
578
|
};
|
|
480
579
|
if (wrapped.metricSource === 'approximation')
|
|
481
580
|
addFidelity(fidelity, 'approximated', 'font_metrics', `No supplied font metrics for ${fontName ?? '<default>'}.`, definition);
|
|
@@ -86,6 +86,22 @@ function renderText(element, toolText) {
|
|
|
86
86
|
(glyphLine === undefined || glyphLine.source === 'deterministic-fallback'
|
|
87
87
|
? text.lineHeight * 0.8
|
|
88
88
|
: glyphLine.baseline);
|
|
89
|
+
const colourRuns = text.colourRuns?.[index] ?? [];
|
|
90
|
+
if (colourRuns.length > 0) {
|
|
91
|
+
const runWidth = colourRuns.reduce((maximum, run) => Math.max(maximum, run.offsetX + run.width), 0);
|
|
92
|
+
const horizontalScale = runWidth > 0 && width > 0 ? width / runWidth : 1;
|
|
93
|
+
return `<g data-hoi4-colour-runs="true">${colourRuns
|
|
94
|
+
.map((run) => toolText.render(run.text, {
|
|
95
|
+
x: originX + run.offsetX * horizontalScale,
|
|
96
|
+
y: baseline,
|
|
97
|
+
fontSize: text.fontSize,
|
|
98
|
+
fill: run.colour,
|
|
99
|
+
stroke: '#12151a',
|
|
100
|
+
strokeWidth: 0.6,
|
|
101
|
+
...(run.width > 0 ? { targetWidth: run.width * horizontalScale } : {}),
|
|
102
|
+
}))
|
|
103
|
+
.join('')}</g>`;
|
|
104
|
+
}
|
|
89
105
|
if (glyphLine?.source === 'fontkit-path') {
|
|
90
106
|
const horizontalScale = glyphLine.width > 0 && width > 0 ? width / glyphLine.width : 1;
|
|
91
107
|
return `<g data-font-sha256="${glyphLine.sourceHash}">${glyphLine.glyphs
|
|
@@ -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,
|