hoi4-agent-tools 1.1.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +21 -18
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/core/workspace.js +31 -3
  5. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  6. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  7. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  8. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  9. package/dist/hoi4_agent_tools/event/index.js +10 -0
  10. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  11. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  12. package/dist/hoi4_agent_tools/event/model.js +2 -0
  13. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  14. package/dist/hoi4_agent_tools/event/render.js +443 -0
  15. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  16. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  17. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  18. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  19. package/dist/hoi4_agent_tools/gui/layout.js +122 -27
  20. package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
  21. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  22. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -2
  23. package/dist/hoi4_agent_tools/mcp/server/result.js +8 -14
  24. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  25. package/dist/hoi4_agent_tools/mcp/tools/focus.js +6 -3
  26. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -3
  27. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
  28. package/dist/hoi4_agent_tools/runtime.js +112 -2
  29. package/dist/hoi4_agent_tools/schemas/common.js +5 -1
  30. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  31. package/dist/hoi4_agent_tools/version.js +1 -1
  32. package/docs/README.md +1 -0
  33. package/docs/development.md +10 -0
  34. package/docs/events.md +113 -0
  35. package/docs/focus.md +1 -1
  36. package/docs/gui.md +3 -1
  37. package/docs/map.md +1 -1
  38. package/docs/setup.md +10 -7
  39. package/package.json +7 -3
  40. package/server.json +3 -3
  41. package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
@@ -5,7 +5,7 @@
5
5
  * Source: the official HOI4 1.19.2 effects documentation reviewed 2026-07-10.
6
6
  * Keep this list synchronized during compatibility reviews.
7
7
  */
8
- export const nativeFocusEffectKeys = new Set([
8
+ export const nativeEffectKeys = new Set([
9
9
  'activate_advisor',
10
10
  'activate_decision',
11
11
  'activate_mission',
@@ -560,3 +560,5 @@ export const nativeFocusEffectKeys = new Set([
560
560
  'while_loop_effect',
561
561
  'white_peace',
562
562
  ]);
563
+ /** Backward-compatible focus-domain name for the shared official effect catalog. */
564
+ export const nativeFocusEffectKeys = nativeEffectKeys;
@@ -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
- return value.replace(/\u00a7[A-Za-z0-9!]/gu, '').replace(/\u00a3[^\u00a3\s]+/gu, '\u25c6');
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(`[?${key}]`);
171
- return `[?${key}]`;
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 match[0];
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
- for (const paragraph of paragraphs) {
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 words = paragraph.split(/\s+/u).filter((word) => word.length > 0);
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
- const committedText = currentWords.join(' ');
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
- const committedText = currentWords.join(' ');
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
- displayText = visibleHoiText(displayText);
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
@@ -6,7 +6,7 @@ const artifactChunkMetadataKey = 'io.github.klimpaskov/hoi4-agent-tools.artifact
6
6
  const fatalUtf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
7
7
  const artifactResourceMetadata = {
8
8
  title: 'HOI4 review artifact',
9
- description: 'Complete plans, diagnostics, layouts, previews, and diffs. Reads accept zero-based byte offset and positive byte length query selectors; each response reports its exact byte range and continuation URI in _meta.',
9
+ description: 'Complete plans, diagnostics, layouts, previews, event graphs, routes, state and scope reports, comparisons, and diffs. Reads accept zero-based byte offset and positive byte length query selectors; each response reports its exact byte range and continuation URI in _meta.',
10
10
  _meta: {
11
11
  [artifactChunkMetadataKey]: {
12
12
  version: 1,
@@ -1,10 +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';
6
+ import { registerEventTools } from '../tools/event.js';
7
7
  import { registerMcpResources } from '../resources/register.js';
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.';
8
9
  export function createMcpServer(engine, context = {}) {
9
10
  const server = new McpServer({
10
11
  name: PACKAGE_NAME,
@@ -12,11 +13,12 @@ export function createMcpServer(engine, context = {}) {
12
13
  websiteUrl: 'https://github.com/klimPaskov/hoi4-agent-tools',
13
14
  }, {
14
15
  capabilities: { logging: {} },
16
+ instructions: SERVER_INSTRUCTIONS,
15
17
  });
16
- registerModsTool(server, engine, context);
17
18
  registerFocusTools(server, engine, context);
18
19
  registerGuiTools(server, engine, context);
19
20
  registerMapTools(server, engine, context);
21
+ registerEventTools(server, engine, context);
20
22
  registerMcpResources(server, engine, context);
21
23
  return server;
22
24
  }
@@ -42,24 +42,18 @@ const emptyToolDataSchema = z.object({}).strict();
42
42
  const compactPublicOperationResultSchema = z
43
43
  .object({
44
44
  status: z.enum(['ok', 'blocked', 'error']),
45
- code: z.string().max(256),
46
- workspaceId: z.string().max(64),
47
- filesScanned: z.array(z.string().max(4096)).max(MAX_INLINE_FILES_SCANNED),
48
- proposedFiles: z.array(z.string().max(1024)).max(MAX_INLINE_PROPOSED_FILES),
49
- changedFiles: z.array(z.string().max(1024)).max(MAX_INLINE_CHANGED_FILES),
45
+ code: z.string(),
46
+ workspaceId: z.string(),
47
+ filesScanned: z.array(z.unknown()).max(MAX_INLINE_FILES_SCANNED),
48
+ proposedFiles: z.array(z.unknown()).max(MAX_INLINE_PROPOSED_FILES),
49
+ changedFiles: z.array(z.unknown()).max(MAX_INLINE_CHANGED_FILES),
50
50
  diagnostics: z.array(z.unknown()).max(MAX_INLINE_DIAGNOSTICS),
51
51
  artifacts: z.array(z.unknown()).max(MAX_INLINE_ARTIFACT_LINKS),
52
- validation: z
53
- .object({
54
- passed: z.boolean(),
55
- checks: z.array(z.unknown()).max(MAX_INLINE_VALIDATION_CHECKS),
56
- })
57
- .strict(),
52
+ validation: z.unknown(),
58
53
  blockers: z.array(z.unknown()).max(MAX_INLINE_BLOCKERS),
59
- data: z.record(z.string().max(256), z.unknown()),
54
+ data: z.object({}).catchall(z.unknown()),
60
55
  })
61
- .strict()
62
- .describe('Stable operation envelope; data is tool-specific and artifacts link bulk evidence.');
56
+ .strict();
63
57
  /**
64
58
  * Advertise one compact operation envelope while retaining exact per-tool runtime validation.
65
59
  * The generic operation-result schema remains available for stored envelopes.