hoi4-agent-tools 1.2.0 → 2.0.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 +8 -1
- package/README.md +3 -5
- package/dist/hoi4_agent_tools/core/workspace.js +31 -3
- package/dist/hoi4_agent_tools/gui/layout.js +122 -27
- package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
- package/dist/hoi4_agent_tools/mcp/server/create.js +1 -3
- package/dist/hoi4_agent_tools/mcp/tools/event.js +6 -6
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +6 -3
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -3
- 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 +1 -1
- package/docs/focus.md +1 -1
- package/docs/gui.md +3 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.0 - 2026-07-15
|
|
4
|
+
|
|
5
|
+
- Removed the workspace inventory tool and made the current mod implicit for local MCP calls.
|
|
6
|
+
- Starting the server inside any mod now works without a config file or per-mod setup command.
|
|
7
|
+
- Updated the published client examples, Registry metadata, and installation verification for the twelve-tool surface.
|
|
8
|
+
- Rendered unresolved scripted localisation values as `[X]` and applied supported HOI4 `§` text colours in offline GUI previews.
|
|
9
|
+
|
|
3
10
|
## 1.2.0 - 2026-07-13
|
|
4
11
|
|
|
5
12
|
- Added a read-only Event Chain Viewer for scanning, tracing, explaining, linting, rendering, and comparing large HOI4 event chains.
|
|
6
|
-
- Kept the event surface to three tools with seven inspect modes and linked authoritative graph artifacts,
|
|
13
|
+
- Kept the event surface to three tools with seven inspect modes and linked authoritative graph artifacts, keeping the public surface to twelve tools within the fixed discovery budget. Local calls now resolve the mod containing the MCP working directory.
|
|
7
14
|
- Added event workflow documentation, package and Registry metadata, and project-owned acceptance coverage for more than 300 event definitions.
|
|
8
15
|
- Reconfirmed MCP protocol `2025-11-25`, TypeScript SDK 1.29.0, Registry schema `2025-12-11`, and Registry publisher 1.7.9 for this release.
|
|
9
16
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# HOI4 Agent Tools
|
|
2
2
|
|
|
3
|
-
HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV event chains and inspect, create, or clean up focus trees, scripted GUIs, and maps.
|
|
3
|
+
HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV event chains and inspect, create, or clean up focus trees, scripted GUIs, and maps. Start it in a mod folder and it works there immediately, with offline renders for review.
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
@@ -14,11 +14,10 @@ HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV
|
|
|
14
14
|
Requires Node.js 22 or 24.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
npm install --global hoi4-agent-tools@
|
|
18
|
-
hoi4-agent-tools-setup --init
|
|
17
|
+
npm install --global hoi4-agent-tools@2.0.0
|
|
19
18
|
```
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
Start the MCP with its working directory set to the mod you are editing. No config or per-mod registration is required: the server finds that mod automatically. Run `hoi4-agent-tools-setup --init` only when you want persistent discovery for several mod roots, a custom game path, or a remote deployment; see [Setup](docs/setup.md).
|
|
22
21
|
|
|
23
22
|
## Connect your agent
|
|
24
23
|
|
|
@@ -32,7 +31,6 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
|
|
|
32
31
|
|
|
33
32
|
| Tool | Purpose |
|
|
34
33
|
| -------------------- | --------------------------------------------------------------------------------------- |
|
|
35
|
-
| `hoi4.mods` | List the writable mods available to the coding agent. |
|
|
36
34
|
| `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
|
|
37
35
|
| `hoi4.focus_render` | Render an existing focus tree or continuous palette. |
|
|
38
36
|
| `hoi4.focus_rewrite` | Create or update a focus tree. |
|
|
@@ -4,6 +4,8 @@ import { WORKSPACE_MAX_REGISTRATIONS, workspaceRegistrationSchema, } from './con
|
|
|
4
4
|
import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
|
|
5
5
|
import { ServiceError } from './result.js';
|
|
6
6
|
import { ServerState } from './server-state.js';
|
|
7
|
+
/** Omitted MCP workspace arguments resolve to the mod containing the server cwd. */
|
|
8
|
+
export const CURRENT_WORKSPACE_ID = 'current';
|
|
7
9
|
async function exists(value, signal) {
|
|
8
10
|
signal?.throwIfAborted();
|
|
9
11
|
try {
|
|
@@ -185,6 +187,9 @@ export class WorkspaceResolver {
|
|
|
185
187
|
}
|
|
186
188
|
}
|
|
187
189
|
for (const configured of configuration.workspaces) {
|
|
190
|
+
if (configured.id === CURRENT_WORKSPACE_ID) {
|
|
191
|
+
throw new ServiceError('WORKSPACE_ID_RESERVED', `Workspace ID is reserved for the current working mod: ${CURRENT_WORKSPACE_ID}`);
|
|
192
|
+
}
|
|
188
193
|
const registration = resolver.withGlobalWorkspaceDefaults(configured);
|
|
189
194
|
const workspace = await resolver.resolveRegistration(registration);
|
|
190
195
|
if (resolver.#byId.has(workspace.id)) {
|
|
@@ -203,6 +208,28 @@ export class WorkspaceResolver {
|
|
|
203
208
|
config() {
|
|
204
209
|
return this.configuration;
|
|
205
210
|
}
|
|
211
|
+
/** Resolve the implicit workspace used by local coding-agent calls. */
|
|
212
|
+
resolveCurrentWorkspaceId(principal) {
|
|
213
|
+
const workspaces = this.list(principal).filter(({ registration }) => registration.kind === 'mod');
|
|
214
|
+
if (workspaces.length === 0) {
|
|
215
|
+
throw new ServiceError('WORKSPACE_CONTEXT_NOT_FOUND', 'No writable mod workspace is configured for the MCP server');
|
|
216
|
+
}
|
|
217
|
+
const currentDirectory = path.normalize(process.cwd());
|
|
218
|
+
const containing = workspaces.filter((workspace) => isWithin(workspace.modRoot, currentDirectory));
|
|
219
|
+
if (containing.length === 1)
|
|
220
|
+
return containing[0].id;
|
|
221
|
+
if (containing.length > 1) {
|
|
222
|
+
throw new ServiceError('WORKSPACE_CONTEXT_AMBIGUOUS', 'The MCP working directory is inside more than one writable mod workspace', { currentDirectory });
|
|
223
|
+
}
|
|
224
|
+
if (workspaces.length === 1)
|
|
225
|
+
return workspaces[0].id;
|
|
226
|
+
throw new ServiceError('WORKSPACE_CONTEXT_REQUIRED', 'The MCP working directory does not identify one writable mod workspace', { currentDirectory, workspaceIds: workspaces.map(({ id }) => id) });
|
|
227
|
+
}
|
|
228
|
+
resolveWorkspaceId(workspaceId, principal) {
|
|
229
|
+
return workspaceId === CURRENT_WORKSPACE_ID
|
|
230
|
+
? this.resolveCurrentWorkspaceId(principal)
|
|
231
|
+
: workspaceId;
|
|
232
|
+
}
|
|
206
233
|
serverState() {
|
|
207
234
|
return this.#serverState;
|
|
208
235
|
}
|
|
@@ -276,15 +303,16 @@ export class WorkspaceResolver {
|
|
|
276
303
|
.sort((a, b) => compareCodeUnits(a.id, b.id));
|
|
277
304
|
}
|
|
278
305
|
get(workspaceId, principal) {
|
|
279
|
-
|
|
306
|
+
const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId, principal);
|
|
307
|
+
if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(resolvedWorkspaceId)) {
|
|
280
308
|
throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
|
|
281
309
|
}
|
|
282
|
-
const workspace = this.#byId.get(
|
|
310
|
+
const workspace = this.#byId.get(resolvedWorkspaceId);
|
|
283
311
|
if (workspace === undefined) {
|
|
284
312
|
if (principal !== undefined) {
|
|
285
313
|
throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
|
|
286
314
|
}
|
|
287
|
-
throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${
|
|
315
|
+
throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${resolvedWorkspaceId}`);
|
|
288
316
|
}
|
|
289
317
|
return workspace;
|
|
290
318
|
}
|
|
@@ -40,8 +40,55 @@ function scalarString(value) {
|
|
|
40
40
|
return String(value);
|
|
41
41
|
return undefined;
|
|
42
42
|
}
|
|
43
|
+
const defaultTextColour = '#f5f2e8';
|
|
44
|
+
const hoi4TextColours = {
|
|
45
|
+
B: '#4aa3df',
|
|
46
|
+
C: '#55d6d6',
|
|
47
|
+
G: '#56b870',
|
|
48
|
+
H: '#e7b454',
|
|
49
|
+
L: '#9aa7b3',
|
|
50
|
+
M: '#d16bd4',
|
|
51
|
+
O: '#e58b3c',
|
|
52
|
+
R: '#e05a5a',
|
|
53
|
+
T: '#4bc6b9',
|
|
54
|
+
W: defaultTextColour,
|
|
55
|
+
Y: '#f1c75b',
|
|
56
|
+
b: '#4aa3df',
|
|
57
|
+
g: '#87939e',
|
|
58
|
+
};
|
|
43
59
|
function visibleHoiText(value) {
|
|
44
|
-
|
|
60
|
+
const characters = [];
|
|
61
|
+
const colours = [];
|
|
62
|
+
let colour;
|
|
63
|
+
let hasColourMarkup = false;
|
|
64
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
65
|
+
const character = value[index] ?? '';
|
|
66
|
+
const next = value[index + 1];
|
|
67
|
+
if (character === '\u00a7' && next !== undefined && /^[A-Za-z0-9!]$/u.test(next)) {
|
|
68
|
+
hasColourMarkup = true;
|
|
69
|
+
colour = next === '!' ? undefined : hoi4TextColours[next];
|
|
70
|
+
index += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (character === '\u00a3') {
|
|
74
|
+
let cursor = index + 1;
|
|
75
|
+
while (cursor < value.length && !/[\s\u00a3]/u.test(value[cursor] ?? ''))
|
|
76
|
+
cursor += 1;
|
|
77
|
+
characters.push('\u25c6');
|
|
78
|
+
colours.push(colour);
|
|
79
|
+
index = cursor - 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (character === '\\' && next === 'n') {
|
|
83
|
+
characters.push('\n');
|
|
84
|
+
colours.push(colour);
|
|
85
|
+
index += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
characters.push(character);
|
|
89
|
+
colours.push(colour);
|
|
90
|
+
}
|
|
91
|
+
return { text: characters.join(''), colours, hasColourMarkup };
|
|
45
92
|
}
|
|
46
93
|
function scalarNumber(value, reference) {
|
|
47
94
|
if (typeof value === 'number')
|
|
@@ -167,13 +214,15 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
|
|
|
167
214
|
const key = match[1] ?? '';
|
|
168
215
|
const replacement = rowValues?.[key] ?? scenario.variables[key] ?? scenario.scriptedGui[key];
|
|
169
216
|
if (replacement === undefined) {
|
|
170
|
-
unresolved.push(
|
|
171
|
-
return
|
|
217
|
+
unresolved.push(match[0]);
|
|
218
|
+
return '[X]';
|
|
172
219
|
}
|
|
173
220
|
return String(replacement);
|
|
174
221
|
}, 'GUI variable substitution');
|
|
175
222
|
text = replaceTextBounded(text, /\[([A-Za-z0-9_.:-]+)\]/gu, (match) => {
|
|
176
223
|
const key = match[1] ?? '';
|
|
224
|
+
if (key === 'X')
|
|
225
|
+
return match[0];
|
|
177
226
|
const countryKey = key.replace(/^(?:ROOT|This)\./u, '');
|
|
178
227
|
const replacement = rowValues?.[countryKey] ??
|
|
179
228
|
scenario.country?.[countryKey] ??
|
|
@@ -181,16 +230,16 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
|
|
|
181
230
|
scenario.scriptedGui[key];
|
|
182
231
|
if (replacement === undefined) {
|
|
183
232
|
unresolved.push(match[0]);
|
|
184
|
-
return
|
|
233
|
+
return '[X]';
|
|
185
234
|
}
|
|
186
235
|
return String(replacement);
|
|
187
236
|
}, 'GUI scope substitution');
|
|
188
237
|
return { text, unresolved: [...new Set(unresolved)].sort(), missingLocalisation };
|
|
189
238
|
}
|
|
190
|
-
function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
191
|
-
const paragraphs = text.replaceAll('\\n', '\n').split('\n');
|
|
239
|
+
function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work) {
|
|
192
240
|
const lines = [];
|
|
193
241
|
const widths = [];
|
|
242
|
+
const colourLines = [];
|
|
194
243
|
const missingGlyphs = new Set();
|
|
195
244
|
const retainMissingGlyphs = (values) => {
|
|
196
245
|
for (const value of values) {
|
|
@@ -201,12 +250,44 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
|
201
250
|
};
|
|
202
251
|
let lineHeight = fontSize * 1.2;
|
|
203
252
|
let metricSource = 'approximation';
|
|
204
|
-
|
|
253
|
+
const commitLine = (words) => {
|
|
254
|
+
const characters = [];
|
|
255
|
+
const lineColours = [];
|
|
256
|
+
for (const [index, word] of words.entries()) {
|
|
257
|
+
if (index > 0) {
|
|
258
|
+
characters.push(' ');
|
|
259
|
+
lineColours.push(lineColours.at(-1) ?? word.colours[0]);
|
|
260
|
+
}
|
|
261
|
+
characters.push(word.text);
|
|
262
|
+
lineColours.push(...word.colours);
|
|
263
|
+
}
|
|
264
|
+
const committedText = characters.join('');
|
|
265
|
+
work.spendTextLayout('committed text line measurement');
|
|
266
|
+
const committed = catalog.measureText(fontName, committedText, fontSize);
|
|
267
|
+
lines.push(committedText);
|
|
268
|
+
widths.push(committed.width);
|
|
269
|
+
colourLines.push(lineColours);
|
|
270
|
+
retainMissingGlyphs(committed.missingGlyphs);
|
|
271
|
+
lineHeight = committed.lineHeight;
|
|
272
|
+
metricSource = committed.source;
|
|
273
|
+
};
|
|
274
|
+
let paragraphStart = 0;
|
|
275
|
+
for (let paragraphEnd = 0; paragraphEnd <= text.length; paragraphEnd += 1) {
|
|
276
|
+
if (paragraphEnd < text.length && text[paragraphEnd] !== '\n')
|
|
277
|
+
continue;
|
|
205
278
|
work.spendTextLayout('text paragraph layout');
|
|
206
|
-
const
|
|
279
|
+
const paragraph = text.slice(paragraphStart, paragraphEnd);
|
|
280
|
+
const paragraphColours = colours.slice(paragraphStart, paragraphEnd);
|
|
281
|
+
const words = [...paragraph.matchAll(/\S+/gu)].map((match) => {
|
|
282
|
+
const start = match.index;
|
|
283
|
+
const word = match[0];
|
|
284
|
+
return { text: word, colours: paragraphColours.slice(start, start + word.length) };
|
|
285
|
+
});
|
|
286
|
+
paragraphStart = paragraphEnd + 1;
|
|
207
287
|
if (words.length === 0) {
|
|
208
288
|
lines.push('');
|
|
209
289
|
widths.push(0);
|
|
290
|
+
colourLines.push([]);
|
|
210
291
|
continue;
|
|
211
292
|
}
|
|
212
293
|
let currentWords = [];
|
|
@@ -215,22 +296,17 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
|
215
296
|
let previousWordWidth = 0;
|
|
216
297
|
for (const word of words) {
|
|
217
298
|
work.spendTextLayout('text word measurement');
|
|
218
|
-
const measuredWord = catalog.measureText(fontName, word, fontSize);
|
|
299
|
+
const measuredWord = catalog.measureText(fontName, word.text, fontSize);
|
|
219
300
|
retainMissingGlyphs(measuredWord.missingGlyphs);
|
|
220
301
|
let candidateWidth = measuredWord.width;
|
|
221
302
|
if (previousWord !== undefined) {
|
|
222
303
|
work.spendTextLayout('text word-boundary measurement');
|
|
223
|
-
const boundary = catalog.measureText(fontName, `${previousWord} ${word}`, fontSize);
|
|
304
|
+
const boundary = catalog.measureText(fontName, `${previousWord} ${word.text}`, fontSize);
|
|
224
305
|
retainMissingGlyphs(boundary.missingGlyphs);
|
|
225
306
|
candidateWidth = currentWidth + boundary.width - previousWordWidth;
|
|
226
307
|
}
|
|
227
308
|
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);
|
|
309
|
+
commitLine(currentWords);
|
|
234
310
|
currentWords = [word];
|
|
235
311
|
currentWidth = measuredWord.width;
|
|
236
312
|
}
|
|
@@ -238,26 +314,40 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
|
|
|
238
314
|
currentWords.push(word);
|
|
239
315
|
currentWidth = candidateWidth;
|
|
240
316
|
}
|
|
241
|
-
previousWord = word;
|
|
317
|
+
previousWord = word.text;
|
|
242
318
|
previousWordWidth = measuredWord.width;
|
|
243
319
|
}
|
|
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;
|
|
320
|
+
commitLine(currentWords);
|
|
252
321
|
}
|
|
253
322
|
return {
|
|
254
323
|
lines,
|
|
255
324
|
widths,
|
|
325
|
+
colourLines,
|
|
256
326
|
lineHeight,
|
|
257
327
|
metricSource,
|
|
258
328
|
missingGlyphs: [...missingGlyphs].sort((a, b) => a - b),
|
|
259
329
|
};
|
|
260
330
|
}
|
|
331
|
+
function colourRunsForLine(catalog, fontName, line, colours, fontSize, work) {
|
|
332
|
+
if (!colours.some((colour) => colour !== undefined) || line.length === 0)
|
|
333
|
+
return [];
|
|
334
|
+
const runs = [];
|
|
335
|
+
let start = 0;
|
|
336
|
+
let offsetX = 0;
|
|
337
|
+
while (start < line.length) {
|
|
338
|
+
const colour = colours[start] ?? defaultTextColour;
|
|
339
|
+
let end = start + 1;
|
|
340
|
+
while (end < line.length && (colours[end] ?? defaultTextColour) === colour)
|
|
341
|
+
end += 1;
|
|
342
|
+
const text = line.slice(start, end);
|
|
343
|
+
work.spendTextLayout('localisation colour-run measurement');
|
|
344
|
+
const width = catalog.measureText(fontName, text, fontSize).width;
|
|
345
|
+
runs.push({ text, colour, offsetX, width });
|
|
346
|
+
offsetX += width;
|
|
347
|
+
start = end;
|
|
348
|
+
}
|
|
349
|
+
return runs;
|
|
350
|
+
}
|
|
261
351
|
function frameFor(element, sprite, scenario) {
|
|
262
352
|
const frameCount = Math.max(1, sprite.frameCount);
|
|
263
353
|
const selected = scenario.selectedFrames[element.name] ?? scenario.selectedFrames[sprite.name];
|
|
@@ -439,7 +529,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
439
529
|
displayText = `${displayText} — ${displayText} — ${displayText}`;
|
|
440
530
|
if (state === 'missing-localisation')
|
|
441
531
|
displayText = `\u00a7R${rawText}_MISSING\u00a7!`;
|
|
442
|
-
|
|
532
|
+
const visibleText = visibleHoiText(displayText);
|
|
533
|
+
displayText = visibleText.text;
|
|
443
534
|
context.work.admitText(displayText, `GUI text for ${definition.name}`);
|
|
444
535
|
const fontName = scalarString(property(definition.attributes, 'font', 'buttonFont'));
|
|
445
536
|
const resolvedFontMetrics = catalog.resolvedFontMetrics(fontName);
|
|
@@ -450,10 +541,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
450
541
|
16;
|
|
451
542
|
const maxWidth = (scalarNumber(property(definition.attributes, 'maxWidth'), width / scale) ?? width / scale) *
|
|
452
543
|
scale;
|
|
453
|
-
const wrapped = wrapText(catalog, fontName, displayText, fontSize * scale, maxWidth, context.work);
|
|
544
|
+
const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work);
|
|
454
545
|
const glyphLines = [];
|
|
455
546
|
for (const line of wrapped.lines)
|
|
456
547
|
glyphLines.push(await catalog.shapeText(fontName, line, fontSize * scale));
|
|
548
|
+
const colourRuns = visibleText.hasColourMarkup
|
|
549
|
+
? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work))
|
|
550
|
+
: undefined;
|
|
457
551
|
const maximumLineWidth = wrapped.widths.reduce((maximum, value) => Math.max(maximum, value), 0);
|
|
458
552
|
if (width === 0)
|
|
459
553
|
width = maximumLineWidth;
|
|
@@ -476,6 +570,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
|
|
|
476
570
|
overflowX: width > 0 && measuredWidth > width + 0.01,
|
|
477
571
|
overflowY: height > 0 && measuredHeight > height + 0.01,
|
|
478
572
|
unresolvedTokens: resolved.unresolved,
|
|
573
|
+
...(colourRuns === undefined ? {} : { colourRuns }),
|
|
479
574
|
};
|
|
480
575
|
if (wrapped.metricSource === 'approximation')
|
|
481
576
|
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
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { PACKAGE_NAME, PACKAGE_VERSION } from '../../version.js';
|
|
3
|
-
import { registerModsTool } from '../tools/mods.js';
|
|
4
3
|
import { registerFocusTools } from '../tools/focus.js';
|
|
5
4
|
import { registerGuiTools } from '../tools/gui.js';
|
|
6
5
|
import { registerMapTools } from '../tools/map.js';
|
|
7
6
|
import { registerEventTools } from '../tools/event.js';
|
|
8
7
|
import { registerMcpResources } from '../resources/register.js';
|
|
9
|
-
export const SERVER_INSTRUCTIONS = '
|
|
8
|
+
export const SERVER_INSTRUCTIONS = 'The workspace defaults to the mod containing the MCP working directory; pass workspaceId only when selecting another configured mod. Use hoi4.event_inspect for scan, roots, trace, explain_path, state_flow, lint, and impact; hoi4.event_render and hoi4.event_compare return linked evidence. JSON resources are authoritative. Event tools are read-only. Only focus, GUI, and map rewrite tools edit configured mods.';
|
|
10
9
|
export function createMcpServer(engine, context = {}) {
|
|
11
10
|
const server = new McpServer({
|
|
12
11
|
name: PACKAGE_NAME,
|
|
@@ -16,7 +15,6 @@ export function createMcpServer(engine, context = {}) {
|
|
|
16
15
|
capabilities: { logging: {} },
|
|
17
16
|
instructions: SERVER_INSTRUCTIONS,
|
|
18
17
|
});
|
|
19
|
-
registerModsTool(server, engine, context);
|
|
20
18
|
registerFocusTools(server, engine, context);
|
|
21
19
|
registerGuiTools(server, engine, context);
|
|
22
20
|
registerMapTools(server, engine, context);
|
|
@@ -306,11 +306,11 @@ export function registerEventTools(server, engine, context) {
|
|
|
306
306
|
outputSchema: eventInspectOutputSchema,
|
|
307
307
|
annotations: readOnlyEventTool,
|
|
308
308
|
}, async (input, extra) => {
|
|
309
|
-
const
|
|
309
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
310
310
|
try {
|
|
311
311
|
const progress = progressReporter(extra);
|
|
312
312
|
await progress.report(0, 3, 'Analyzing event chain');
|
|
313
|
-
const inspected = await viewer.inspect(inspectRequest(input, context, progress.signal));
|
|
313
|
+
const inspected = await viewer.inspect(inspectRequest({ ...input, workspaceId }, context, progress.signal));
|
|
314
314
|
await progress.report(2, 3, 'Linking complete event analysis');
|
|
315
315
|
const result = emptyServiceResult(workspaceId, {
|
|
316
316
|
mode: inspected.mode,
|
|
@@ -344,11 +344,11 @@ export function registerEventTools(server, engine, context) {
|
|
|
344
344
|
outputSchema: eventRenderOutputSchema,
|
|
345
345
|
annotations: readOnlyEventTool,
|
|
346
346
|
}, async (input, extra) => {
|
|
347
|
-
const
|
|
347
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
348
348
|
try {
|
|
349
349
|
const progress = progressReporter(extra);
|
|
350
350
|
await progress.report(0, 3, 'Rendering event-chain view');
|
|
351
|
-
const rendered = await viewer.renderAndStore(renderRequest(input, context, progress.signal));
|
|
351
|
+
const rendered = await viewer.renderAndStore(renderRequest({ ...input, workspaceId }, context, progress.signal));
|
|
352
352
|
await progress.report(2, 3, 'Linking complete event renders');
|
|
353
353
|
const result = emptyServiceResult(workspaceId, {
|
|
354
354
|
view: rendered.render.view,
|
|
@@ -389,11 +389,11 @@ export function registerEventTools(server, engine, context) {
|
|
|
389
389
|
outputSchema: eventCompareOutputSchema,
|
|
390
390
|
annotations: readOnlyEventTool,
|
|
391
391
|
}, async (input, extra) => {
|
|
392
|
-
const
|
|
392
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
393
393
|
try {
|
|
394
394
|
const progress = progressReporter(extra);
|
|
395
395
|
await progress.report(0, 3, 'Comparing event-chain graphs');
|
|
396
|
-
const compared = await viewer.compareAndStore(compareRequest(input, context, progress.signal));
|
|
396
|
+
const compared = await viewer.compareAndStore(compareRequest({ ...input, workspaceId }, context, progress.signal));
|
|
397
397
|
await progress.report(2, 3, 'Linking complete event comparison');
|
|
398
398
|
const { comparison } = compared;
|
|
399
399
|
const result = emptyServiceResult(workspaceId, {
|
|
@@ -451,7 +451,8 @@ export function registerFocusTools(server, engine, context) {
|
|
|
451
451
|
outputSchema: focusInspectOutputSchema,
|
|
452
452
|
annotations: artifactProducing,
|
|
453
453
|
}, async (input, extra) => {
|
|
454
|
-
const { workspaceId, relativePath } = input;
|
|
454
|
+
const { workspaceId: requestedWorkspaceId, relativePath } = input;
|
|
455
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
|
|
455
456
|
try {
|
|
456
457
|
const progress = progressReporter(extra);
|
|
457
458
|
await progress.report(0, 3, 'Building shared workspace index');
|
|
@@ -667,7 +668,8 @@ export function registerFocusTools(server, engine, context) {
|
|
|
667
668
|
outputSchema: focusRenderOutputSchema,
|
|
668
669
|
annotations: artifactProducing,
|
|
669
670
|
}, async (input, extra) => {
|
|
670
|
-
const { workspaceId, relativePath } = input;
|
|
671
|
+
const { workspaceId: requestedWorkspaceId, relativePath } = input;
|
|
672
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
|
|
671
673
|
try {
|
|
672
674
|
const progress = progressReporter(extra);
|
|
673
675
|
await progress.report(0, 3, 'Importing and indexing focus source');
|
|
@@ -790,7 +792,8 @@ export function registerFocusTools(server, engine, context) {
|
|
|
790
792
|
openWorldHint: false,
|
|
791
793
|
},
|
|
792
794
|
}, async (input, extra) => {
|
|
793
|
-
const { workspaceId, relativePath, createIfMissing, horizontalSpacing, verticalSpacing, padding, reviewScale, } = input;
|
|
795
|
+
const { workspaceId: requestedWorkspaceId, relativePath, createIfMissing, horizontalSpacing, verticalSpacing, padding, reviewScale, } = input;
|
|
796
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
|
|
794
797
|
try {
|
|
795
798
|
requireServerScope(context, 'hoi4:write');
|
|
796
799
|
const progress = progressReporter(extra);
|
|
@@ -149,7 +149,8 @@ export function registerGuiTools(server, engine, context) {
|
|
|
149
149
|
inputSchema: guiInspectInput,
|
|
150
150
|
outputSchema: guiScanOutputSchema,
|
|
151
151
|
annotations: artifactProducing,
|
|
152
|
-
}, async ({ workspaceId, windowName, scenario, relatedScenarios }, extra) => {
|
|
152
|
+
}, async ({ workspaceId: requestedWorkspaceId, windowName, scenario, relatedScenarios }, extra) => {
|
|
153
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
|
|
153
154
|
try {
|
|
154
155
|
const progress = progressReporter(extra);
|
|
155
156
|
await progress.report(0, 3, 'Building shared index and GUI inspection');
|
|
@@ -253,7 +254,8 @@ export function registerGuiTools(server, engine, context) {
|
|
|
253
254
|
}
|
|
254
255
|
});
|
|
255
256
|
const render = async (input, extra, code) => {
|
|
256
|
-
const
|
|
257
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
258
|
+
const { windowName } = input;
|
|
257
259
|
try {
|
|
258
260
|
const progress = progressReporter(extra);
|
|
259
261
|
await progress.report(0, 4, 'Building shared index and GUI source graph');
|
|
@@ -444,7 +446,8 @@ export function registerGuiTools(server, engine, context) {
|
|
|
444
446
|
openWorldHint: false,
|
|
445
447
|
},
|
|
446
448
|
}, async (input, extra) => {
|
|
447
|
-
const
|
|
449
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
|
|
450
|
+
const { relativePath, windowName, scenario } = input;
|
|
448
451
|
try {
|
|
449
452
|
requireServerScope(context, 'hoi4:write');
|
|
450
453
|
const progress = progressReporter(extra);
|
|
@@ -364,7 +364,8 @@ export function registerMapTools(server, engine, context) {
|
|
|
364
364
|
.strict(),
|
|
365
365
|
outputSchema: mapWorkspaceInspectOutputSchema,
|
|
366
366
|
annotations: artifactProducing,
|
|
367
|
-
}, async ({ workspaceId, provinceIds, stateIds, regionIds, allocationRequests }, extra) => {
|
|
367
|
+
}, async ({ workspaceId: requestedWorkspaceId, provinceIds, stateIds, regionIds, allocationRequests }, extra) => {
|
|
368
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
|
|
368
369
|
try {
|
|
369
370
|
const progress = progressReporter(extra);
|
|
370
371
|
await progress.report(0, 3, 'Inspecting and validating the map');
|
|
@@ -512,7 +513,8 @@ export function registerMapTools(server, engine, context) {
|
|
|
512
513
|
.strict(),
|
|
513
514
|
outputSchema: mapRenderOutputSchema,
|
|
514
515
|
annotations: artifactProducing,
|
|
515
|
-
}, async ({ workspaceId, layer, overlays, scale }, extra) => {
|
|
516
|
+
}, async ({ workspaceId: requestedWorkspaceId, layer, overlays, scale }, extra) => {
|
|
517
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
|
|
516
518
|
try {
|
|
517
519
|
const progress = progressReporter(extra);
|
|
518
520
|
await progress.report(0, 3, 'Rendering offline map layer');
|
|
@@ -557,7 +559,8 @@ export function registerMapTools(server, engine, context) {
|
|
|
557
559
|
idempotentHint: false,
|
|
558
560
|
openWorldHint: false,
|
|
559
561
|
},
|
|
560
|
-
}, async ({ workspaceId, operations, diffScale }, extra) => {
|
|
562
|
+
}, async ({ workspaceId: requestedWorkspaceId, operations, diffScale }, extra) => {
|
|
563
|
+
const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
|
|
561
564
|
try {
|
|
562
565
|
requireServerScope(context, 'hoi4:write');
|
|
563
566
|
const progress = progressReporter(extra);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
|
-
import { loadConfiguration } from './core/configuration.js';
|
|
4
|
+
import { loadConfiguration, serverConfigurationSchema, } from './core/configuration.js';
|
|
4
5
|
import { CoreEngine } from './core/engine.js';
|
|
5
6
|
import { ServiceError } from './core/result.js';
|
|
6
7
|
import { WorkspaceResolver } from './core/workspace.js';
|
|
8
|
+
import { CONFIG_VERSION } from './version.js';
|
|
7
9
|
export function defaultConfigurationPath() {
|
|
8
10
|
const configRoot = process.env.APPDATA ?? process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config');
|
|
9
11
|
return path.join(configRoot, 'hoi4-agent-tools', 'config.json');
|
|
@@ -21,8 +23,116 @@ export function configurationPath(arguments_ = process.argv.slice(2)) {
|
|
|
21
23
|
return path.resolve(process.env.HOI4_AGENT_CONFIG);
|
|
22
24
|
return defaultConfigurationPath();
|
|
23
25
|
}
|
|
26
|
+
async function isDirectory(candidate) {
|
|
27
|
+
try {
|
|
28
|
+
return (await stat(candidate)).isDirectory();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function isFile(candidate) {
|
|
35
|
+
try {
|
|
36
|
+
return (await stat(candidate)).isFile();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function findCurrentModRoot(start = process.cwd()) {
|
|
43
|
+
for (let cursor = path.resolve(start);;) {
|
|
44
|
+
if ((await isFile(path.join(cursor, 'descriptor.mod'))) ||
|
|
45
|
+
((await isDirectory(path.join(cursor, 'common'))) &&
|
|
46
|
+
((await isDirectory(path.join(cursor, 'history'))) ||
|
|
47
|
+
(await isDirectory(path.join(cursor, 'interface'))) ||
|
|
48
|
+
(await isDirectory(path.join(cursor, 'map')))))) {
|
|
49
|
+
return cursor;
|
|
50
|
+
}
|
|
51
|
+
const parent = path.dirname(cursor);
|
|
52
|
+
if (parent === cursor)
|
|
53
|
+
break;
|
|
54
|
+
cursor = parent;
|
|
55
|
+
}
|
|
56
|
+
throw new ServiceError('AUTO_MOD_ROOT_NOT_FOUND', 'Start the MCP from inside a Hearts of Iron IV mod or configure a server workspace', { currentDirectory: path.resolve(start) });
|
|
57
|
+
}
|
|
58
|
+
async function firstExistingDirectory(candidates) {
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
if (await isDirectory(candidate))
|
|
61
|
+
return path.resolve(candidate);
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function automaticGameRootCandidates() {
|
|
66
|
+
const home = homedir();
|
|
67
|
+
return [
|
|
68
|
+
...(process.env.HOI4_GAME_ROOT === undefined ? [] : [process.env.HOI4_GAME_ROOT]),
|
|
69
|
+
path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
|
|
70
|
+
path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
|
|
71
|
+
path.join(home, '.steam', 'steam', 'steamapps', 'common', 'Hearts of Iron IV'),
|
|
72
|
+
path.join(home, 'Library', 'Application Support', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
function automaticStorageRoots() {
|
|
76
|
+
const home = homedir();
|
|
77
|
+
const dataRoot = process.env.XDG_DATA_HOME ?? process.env.LOCALAPPDATA ?? path.join(home, '.local', 'share');
|
|
78
|
+
const stateRoot = process.env.XDG_STATE_HOME ?? process.env.LOCALAPPDATA ?? path.join(home, '.local', 'state');
|
|
79
|
+
return {
|
|
80
|
+
serverStateRoot: path.join(stateRoot, 'hoi4-agent-tools', 'state'),
|
|
81
|
+
workspaceStorageRoot: path.join(dataRoot, 'hoi4-agent-tools', 'workspaces'),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function automaticWorkspaceId(modRoot) {
|
|
85
|
+
const slug = path
|
|
86
|
+
.basename(modRoot)
|
|
87
|
+
.normalize('NFKD')
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/[^a-z0-9]+/gu, '_')
|
|
90
|
+
.replace(/^_+|_+$/gu, '')
|
|
91
|
+
.slice(0, 48);
|
|
92
|
+
return `auto_${slug.length === 0 ? 'mod' : slug}`;
|
|
93
|
+
}
|
|
94
|
+
/** Build an in-memory configuration when a local agent starts inside a mod. */
|
|
95
|
+
export async function automaticConfiguration(start = process.cwd()) {
|
|
96
|
+
const modRoot = await findCurrentModRoot(start);
|
|
97
|
+
const gameRoot = await firstExistingDirectory(automaticGameRootCandidates());
|
|
98
|
+
const storage = automaticStorageRoots();
|
|
99
|
+
return serverConfigurationSchema.parse({
|
|
100
|
+
version: CONFIG_VERSION,
|
|
101
|
+
serverStateRoot: storage.serverStateRoot,
|
|
102
|
+
workspaceStorageRoot: storage.workspaceStorageRoot,
|
|
103
|
+
...(gameRoot === undefined ? {} : { gameRoot }),
|
|
104
|
+
workspaces: [
|
|
105
|
+
{
|
|
106
|
+
id: automaticWorkspaceId(modRoot),
|
|
107
|
+
name: path.basename(modRoot),
|
|
108
|
+
root: modRoot,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async function configurationExists(configPath) {
|
|
114
|
+
try {
|
|
115
|
+
return (await stat(configPath)).isFile();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
24
121
|
export async function createEngine(configPath = configurationPath()) {
|
|
25
|
-
|
|
122
|
+
let configuration;
|
|
123
|
+
try {
|
|
124
|
+
configuration = await loadConfiguration(configPath);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const useAutomaticConfiguration = configPath === defaultConfigurationPath() &&
|
|
128
|
+
process.env.HOI4_AGENT_CONFIG === undefined &&
|
|
129
|
+
error instanceof ServiceError &&
|
|
130
|
+
error.code === 'CONFIG_READ_FAILED' &&
|
|
131
|
+
!(await configurationExists(configPath));
|
|
132
|
+
if (!useAutomaticConfiguration)
|
|
133
|
+
throw error;
|
|
134
|
+
configuration = await automaticConfiguration();
|
|
135
|
+
}
|
|
26
136
|
const resolver = await WorkspaceResolver.create(configuration);
|
|
27
137
|
const engine = new CoreEngine(resolver);
|
|
28
138
|
await engine.initialize();
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod/v4';
|
|
2
|
-
|
|
2
|
+
/** Optional for local agents: omitted means the mod containing the MCP cwd. */
|
|
3
|
+
export const workspaceIdSchema = z
|
|
4
|
+
.string()
|
|
5
|
+
.regex(/^[a-z][a-z0-9_-]{0,63}$/u)
|
|
6
|
+
.default('current');
|
|
3
7
|
const diagnosticCodeSchema = z.string().min(1).max(256);
|
|
4
8
|
const diagnosticMessageSchema = z.string().max(4096);
|
|
5
9
|
const sourceDisplayPathSchema = z.string().min(1).max(4096);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
export const PACKAGE_NAME = 'hoi4-agent-tools';
|
|
3
|
-
export const PACKAGE_VERSION = '
|
|
3
|
+
export const PACKAGE_VERSION = '2.0.0';
|
|
4
4
|
export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
|
|
5
5
|
export const CONFIG_VERSION = 1;
|
|
6
6
|
export const TRANSACTION_VERSION = 1;
|
package/docs/events.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The Event Chain Viewer analyzes event definitions and call sites across the active mod, configured dependencies, and vanilla sources. It is read-only: it does not rewrite event files or simulate the game.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Use the three event tools directly; omit `workspaceId` when the MCP working directory is inside the target mod.
|
|
6
6
|
|
|
7
7
|
## Inspect
|
|
8
8
|
|
package/docs/focus.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Focus trees
|
|
2
2
|
|
|
3
|
-
Use `hoi4.focus_inspect`, `hoi4.focus_render`, and `hoi4.focus_rewrite` for national focus trees and continuous focus palettes.
|
|
3
|
+
Use `hoi4.focus_inspect`, `hoi4.focus_render`, and `hoi4.focus_rewrite` for national focus trees and continuous focus palettes. Omit `workspaceId` when the MCP working directory is inside the target mod.
|
|
4
4
|
|
|
5
5
|
## Create a tree
|
|
6
6
|
|
package/docs/gui.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Scripted GUIs
|
|
2
2
|
|
|
3
|
-
Use `hoi4.gui_inspect`, `hoi4.gui_render`, and `hoi4.gui_rewrite` for `.gui`, `.gfx`, `common/scripted_guis`, localisation, sprites, fonts, and linked decision entry points.
|
|
3
|
+
Use `hoi4.gui_inspect`, `hoi4.gui_render`, and `hoi4.gui_rewrite` for `.gui`, `.gfx`, `common/scripted_guis`, localisation, sprites, fonts, and linked decision entry points. Omit `workspaceId` when the MCP working directory is inside the target mod.
|
|
4
4
|
|
|
5
5
|
## Create a GUI
|
|
6
6
|
|
|
@@ -34,4 +34,6 @@ Inspection checks missing assets and localisation, invalid sizes, overlap, clipp
|
|
|
34
34
|
|
|
35
35
|
The renderer does not run the game engine. Each render includes a fidelity report that separates fields it models from fields it approximates, ignores, cannot resolve, or does not support. Treat that report as part of the review.
|
|
36
36
|
|
|
37
|
+
Unresolved runtime localisation expressions such as `[GetValue]` and `[?variable|format]` render as the compact placeholder `[X]`; provide scenario values when the exact text matters. Supported HOI4 `§` localisation colour controls are applied to preview text and `§!` restores the default colour.
|
|
38
|
+
|
|
37
39
|
A rewrite stops if malformed or unsupported GUI script makes the requested change ambiguous. Runtime animation, masking, tiling, hardcoded controls, and dynamic values may require exact game precedents even when a useful offline preview is available.
|
package/docs/map.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Maps
|
|
2
2
|
|
|
3
|
-
Use `hoi4.map_inspect`, `hoi4.map_render`, and `hoi4.map_rewrite` for provinces, states, strategic regions, adjacency, supply nodes, railways, positions, ownership, cores, claims, resources, buildings, and localisation.
|
|
3
|
+
Use `hoi4.map_inspect`, `hoi4.map_render`, and `hoi4.map_rewrite` for provinces, states, strategic regions, adjacency, supply nodes, railways, positions, ownership, cores, claims, resources, buildings, and localisation. Omit `workspaceId` when the MCP working directory is inside the target mod.
|
|
4
4
|
|
|
5
5
|
## Create or reshape map content
|
|
6
6
|
|
package/docs/setup.md
CHANGED
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
HOI4 Agent Tools requires Node.js 22 or 24.
|
|
4
4
|
|
|
5
|
-
## Install
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install --global hoi4-agent-tools
|
|
9
|
-
hoi4-agent-tools-setup --init
|
|
8
|
+
npm install --global hoi4-agent-tools
|
|
10
9
|
```
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
Set the MCP process working directory to the mod being edited. The server creates its local workspace in memory, keeps generated artifacts in the per-user data directory, and starts without a config file. The installed game is detected when available for vanilla references; it is not required for source inspection or offline rendering.
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
When the MCP server starts with its working directory inside a mod, every focus, GUI, map, and event call uses that mod automatically. Omit `workspaceId`. Explicit `workspaceId` values remain available for configured multi-mod or remote deployments.
|
|
15
14
|
|
|
16
|
-
##
|
|
15
|
+
## Optional persistent configuration
|
|
16
|
+
|
|
17
|
+
Use `--init` only when you need several mod roots, a fixed game path, or a shared/remote process:
|
|
18
|
+
|
|
19
|
+
### Configure explicit paths
|
|
17
20
|
|
|
18
21
|
Use explicit paths when discovery is incomplete or when you want to expose more than one mod root:
|
|
19
22
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoi4-agent-tools",
|
|
3
3
|
"mcpName": "io.github.klimPaskov/hoi4-agent-tools",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"description": "Analyze HOI4 event chains; create and clean focus trees, scripted GUIs, and maps with coding agents.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "Apache-2.0",
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.klimPaskov/hoi4-agent-tools",
|
|
4
4
|
"title": "HOI4 Agent Tools",
|
|
5
5
|
"description": "Analyze HOI4 event chains; create and clean focus trees, scripted GUIs, and maps with coding agents.",
|
|
6
|
-
"version": "
|
|
6
|
+
"version": "2.0.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/klimPaskov/hoi4-agent-tools",
|
|
9
9
|
"source": "github"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "hoi4-agent-tools",
|
|
16
|
-
"version": "
|
|
16
|
+
"version": "2.0.0",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod/v4';
|
|
2
|
-
import { compareCodeUnits } from '../../core/canonical.js';
|
|
3
|
-
import { emptyServiceResult } from '../../core/result.js';
|
|
4
|
-
import { nonNegativeIntegerSchema } from '../server/output-schemas.js';
|
|
5
|
-
import { errorResult, strictOperationResultSchema, toolResult } from '../server/result.js';
|
|
6
|
-
const modsOutputSchema = strictOperationResultSchema(z
|
|
7
|
-
.object({
|
|
8
|
-
count: nonNegativeIntegerSchema,
|
|
9
|
-
mods: z
|
|
10
|
-
.array(z
|
|
11
|
-
.object({
|
|
12
|
-
id: z.string().min(1).max(64),
|
|
13
|
-
name: z.string().min(1).max(256),
|
|
14
|
-
writable: z.boolean(),
|
|
15
|
-
})
|
|
16
|
-
.strict())
|
|
17
|
-
.max(1_000),
|
|
18
|
-
})
|
|
19
|
-
.strict());
|
|
20
|
-
export function registerModsTool(server, engine, context) {
|
|
21
|
-
server.registerTool('hoi4.mods', {
|
|
22
|
-
title: 'List available HOI4 mods',
|
|
23
|
-
description: 'List configured HOI4 mod workspaces, optionally selecting one by ID.',
|
|
24
|
-
inputSchema: z.object({ workspaceId: z.string().min(1).max(64).optional() }).strict(),
|
|
25
|
-
outputSchema: modsOutputSchema,
|
|
26
|
-
annotations: {
|
|
27
|
-
readOnlyHint: true,
|
|
28
|
-
destructiveHint: false,
|
|
29
|
-
idempotentHint: true,
|
|
30
|
-
openWorldHint: false,
|
|
31
|
-
},
|
|
32
|
-
}, ({ workspaceId }) => {
|
|
33
|
-
try {
|
|
34
|
-
const mods = (workspaceId === undefined
|
|
35
|
-
? engine.list(context.principal)
|
|
36
|
-
: [engine.status(workspaceId, context.principal)])
|
|
37
|
-
.filter(({ kind }) => kind === 'mod')
|
|
38
|
-
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
39
|
-
const result = emptyServiceResult(workspaceId ?? '', {
|
|
40
|
-
count: mods.length,
|
|
41
|
-
mods: mods.map(({ id, name, writable }) => ({
|
|
42
|
-
id,
|
|
43
|
-
name,
|
|
44
|
-
writable,
|
|
45
|
-
})),
|
|
46
|
-
});
|
|
47
|
-
result.code = 'MODS_LISTED';
|
|
48
|
-
return toolResult(result);
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
return errorResult(error, workspaceId ?? '');
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
}
|