hoi4-agent-tools 2.3.13 → 2.5.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 (38) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +28 -28
  3. package/dist/hoi4_agent_tools/gui/layout.js +12 -6
  4. package/dist/hoi4_agent_tools/gui/scenario.js +10 -0
  5. package/dist/hoi4_agent_tools/gui/studio.js +113 -8
  6. package/dist/hoi4_agent_tools/gui/validators.js +198 -11
  7. package/dist/hoi4_agent_tools/mcp/tools/focus.js +8 -1
  8. package/dist/hoi4_agent_tools/mcp/tools/gui.js +13 -2
  9. package/dist/hoi4_agent_tools/mcp/tools/probability.js +52 -6
  10. package/dist/hoi4_agent_tools/probability/service.js +81 -7
  11. package/dist/hoi4_agent_tools/probability/source-analysis.js +83 -32
  12. package/dist/hoi4_agent_tools/version.js +1 -1
  13. package/docs/focus.md +1 -1
  14. package/docs/gui.md +25 -5
  15. package/docs/probability.md +3 -1
  16. package/docs/setup.md +1 -1
  17. package/package.json +13 -12
  18. package/schemas/configuration.schema.json +1 -1
  19. package/schemas/continuous-focus-palette.schema.json +1 -1
  20. package/schemas/custom-weighted-pool.schema.json +1 -1
  21. package/schemas/event-feature-manifest.schema.json +1 -1
  22. package/schemas/focus-plan.schema.json +1 -1
  23. package/schemas/focus-planning-sidecar.schema.json +1 -1
  24. package/schemas/gui-animation-source.schema.json +1 -1
  25. package/schemas/gui-helper.schema.json +1 -1
  26. package/schemas/gui-scenario.schema.json +57 -1
  27. package/schemas/map-operation.schema.json +1 -1
  28. package/schemas/operation-result.schema.json +1 -1
  29. package/schemas/probability-analysis-result.schema.json +1 -1
  30. package/schemas/probability-compare-input.schema.json +1 -1
  31. package/schemas/probability-evaluate-input.schema.json +1 -1
  32. package/schemas/probability-inspect-input.schema.json +1 -1
  33. package/schemas/probability-render-input.schema.json +1 -1
  34. package/schemas/probability-scenario-set.schema.json +1 -1
  35. package/schemas/probability-sequence-input.schema.json +1 -1
  36. package/schemas/probability-simulate-input.schema.json +1 -1
  37. package/schemas/probability-sweep-input.schema.json +1 -1
  38. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0 - 2026-08-09
4
+
5
+ - Added exact continuous-focus coordinates and linked palette IDs to compact national focus inspection results.
6
+ - Added source-first probability inspection that discovers compatible adapters, candidate counts, and selector examples without requiring the caller to guess a surface type.
7
+ - Changed empty or mismatched probability inspection into an actionable discovery result with a reason and suggested adapter while keeping evaluation, sweeps, and simulation strict.
8
+ - Prevented focus-tree blocks from being misclassified as decision or mission weighted surfaces.
9
+
10
+ ## 2.4.0 - 2026-08-09
11
+
12
+ - Added named value-driven scripted-GUI scenario matrices with deterministic SVG, PNG, JSON, and per-element visibility, movement, size, text, and frame-change evidence.
13
+ - Added scenario assertions for visible and hidden elements, background or panel containment, and exact label-to-control centering, with source-linked diagnostics across every supplied variant.
14
+ - Modelled native HOI4 button-text centering and added automatic diagnostics for visibly off-center labels and text or controls crossing a parent background boundary.
15
+ - Updated the production MCP SDK to 1.30.0, the official Inspector to 2.1.0, the Registry publisher to 1.8.1, and patched dependencies; the dependency audit reports no known vulnerabilities.
16
+ - Updated the Inspector harness for the current CLI entry point, result envelope, explicit environment forwarding, and longer connection startup budget.
17
+ - Generalized installed-game integration tests so mods without their own focus, event, or technology definitions still qualify and use the available vanilla-backed analysis surfaces.
18
+
3
19
  ## 2.3.13 - 2026-08-06
4
20
 
5
21
  - Repacked sparse sibling fan-outs around their structural parent instead of preserving avoidable long prerequisite connectors.
package/README.md CHANGED
@@ -5,7 +5,7 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
5
5
  ## What it does
6
6
 
7
7
  - Focus trees: inspect structure and references, render layouts, create trees, and reorganize existing branches.
8
- - Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render states; create or repair interface source.
8
+ - Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render value-driven variants, states, resolutions, hierarchy, and click regions; diagnose alignment, visibility, clipping, panel containment, and button-label centering; create or repair interface source.
9
9
  - Maps: inspect provinces, states, regions, adjacency, supply, and railways; render layers; create and repair exact map data.
10
10
  - Event chains: scan definitions and call sites, trace routes and state flow, lint references, render graphs, and compare revisions without editing event source.
11
11
  - Technology trees: reconstruct technology and doctrine paths, folder layouts, unlocks, bonuses, grants, metadata, assets, and structural changes.
@@ -13,7 +13,7 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
13
13
 
14
14
  ## Setup
15
15
 
16
- Requires Node.js 22 or 24.
16
+ Requires Node.js 22.19 or later in the Node 22 line, or Node.js 24.
17
17
 
18
18
  ```bash
19
19
  npm install --global hoi4-agent-tools@latest
@@ -31,31 +31,31 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
31
31
 
32
32
  ## Tools
33
33
 
34
- | Tool | Purpose |
35
- | --------------------------- | --------------------------------------------------------------------------------------- |
36
- | `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
37
- | `hoi4.focus_render` | Produce fast HTML, SVG, JSON, and source-linked layout artifacts. |
38
- | `hoi4.focus_raster` | Produce a high-fidelity PNG review with decoded source icons. |
39
- | `hoi4.focus_rewrite` | Create or update a focus tree. |
40
- | `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
41
- | `hoi4.gui_render` | Render GUI states and resolutions offline. |
42
- | `hoi4.gui_rewrite` | Create or update a GUI source package. |
43
- | `hoi4.map_inspect` | Read map, state, province, region, supply, and railway data. |
44
- | `hoi4.map_render` | Render map layers and overlays. |
45
- | `hoi4.map_rewrite` | Create or update map data from an ordered list of exact changes. |
46
- | `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
47
- | `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
48
- | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
49
- | `hoi4.tech_inspect` | Scan, trace, explain, lint, and assess technology and doctrine systems. |
50
- | `hoi4.tech_render` | Render source layouts, dependency paths, unlocks, grants, metadata, and asset coverage. |
51
- | `hoi4.tech_compare` | Compare technology graphs, placements, references, diagnostics, and source overlays. |
52
- | `hoi4.probability_inspect` | Locate weighted logic, candidates, required inputs, and supported analysis. |
53
- | `hoi4.probability_evaluate` | Evaluate exact values, probabilities, timing, bounds, and unresolved inputs. |
54
- | `hoi4.probability_sweep` | Find sensitivity, breakpoints, cliffs, and rank reversals across declared ranges. |
55
- | `hoi4.probability_simulate` | Run deterministic sampled analysis with confidence and convergence data. |
56
- | `hoi4.probability_sequence` | Analyze declared recovery, caps, cooldowns, resets, timers, and terminal states. |
57
- | `hoi4.probability_compare` | Attribute AI-weight and MTTH changes between real or proposed source. |
58
- | `hoi4.probability_render` | Render cached rankings, matrices, timing, sensitivity, sequence, and comparisons. |
34
+ | Tool | Purpose |
35
+ | --------------------------- | ---------------------------------------------------------------------------------------- |
36
+ | `hoi4.focus_inspect` | Read focus trees, continuous-focus placement, and structural or reference problems. |
37
+ | `hoi4.focus_render` | Produce fast HTML, SVG, JSON, and source-linked layout artifacts. |
38
+ | `hoi4.focus_raster` | Produce a high-fidelity PNG review with decoded source icons. |
39
+ | `hoi4.focus_rewrite` | Create or update a focus tree. |
40
+ | `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
41
+ | `hoi4.gui_render` | Render GUI scenario variants, states, resolutions, hierarchy, and layout diagnostics. |
42
+ | `hoi4.gui_rewrite` | Create or update a GUI source package. |
43
+ | `hoi4.map_inspect` | Read map, state, province, region, supply, and railway data. |
44
+ | `hoi4.map_render` | Render map layers and overlays. |
45
+ | `hoi4.map_rewrite` | Create or update map data from an ordered list of exact changes. |
46
+ | `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
47
+ | `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
48
+ | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
49
+ | `hoi4.tech_inspect` | Scan, trace, explain, lint, and assess technology and doctrine systems. |
50
+ | `hoi4.tech_render` | Render source layouts, dependency paths, unlocks, grants, metadata, and asset coverage. |
51
+ | `hoi4.tech_compare` | Compare technology graphs, placements, references, diagnostics, and source overlays. |
52
+ | `hoi4.probability_inspect` | Locate weighted logic and discover compatible adapters, candidates, and required inputs. |
53
+ | `hoi4.probability_evaluate` | Evaluate exact values, probabilities, timing, bounds, and unresolved inputs. |
54
+ | `hoi4.probability_sweep` | Find sensitivity, breakpoints, cliffs, and rank reversals across declared ranges. |
55
+ | `hoi4.probability_simulate` | Run deterministic sampled analysis with confidence and convergence data. |
56
+ | `hoi4.probability_sequence` | Analyze declared recovery, caps, cooldowns, resets, timers, and terminal states. |
57
+ | `hoi4.probability_compare` | Attribute AI-weight and MTTH changes between real or proposed source. |
58
+ | `hoi4.probability_render` | Render cached rankings, matrices, timing, sensitivity, sequence, and comparisons. |
59
59
 
60
60
  Large outputs are linked `hoi4-agent://` resources. For resources over 1 MiB, follow the `continuationUri` returned in `_meta` until it is `null`; clients may also request byte ranges with `?offset=<bytes>&length=<bytes>`.
61
61
 
@@ -70,7 +70,7 @@ Connecting and listing tools does not scan mod source. Compact tool schemas and
70
70
  Ask your agent in normal task language. A typical workflow is inspect, render, rewrite, then inspect the result. The agent can call a raster tool when a pixel review is useful without paying that cost during every structural operation.
71
71
 
72
72
  - Focus trees: "Create a complete national focus tree for this route specification," or "Compact this existing tree into a balanced, readable layout." Existing trees can use a plan-free compact reflow; new trees use a complete plan. See [Focus trees](docs/focus.md).
73
- - Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render this window at common resolutions and fix clipping, missing assets, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
73
+ - Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render every value-driven version of this window and fix hidden controls, off-center button text, background alignment, clipping, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
74
74
  - Maps: "Create a state from these exact provinces," or "Inspect this state and split these provinces while keeping supply and railway references valid." See [Maps](docs/map.md).
75
75
  - Event chains: "Trace every route from this event and explain where its flags and variables change," or "Compare the workspace event graph with its previous revision and render the affected routes." See [Event chains](docs/events.md).
76
76
  - Technology trees: "Explain everything this technology requires and unlocks," or "Compare this technology patch and render every affected folder and doctrine branch." See [Technology trees](docs/technology.md).
@@ -384,18 +384,23 @@ function frameFor(element, sprite, scenario) {
384
384
  : 0;
385
385
  return Math.min(frameCount - 1, mapped);
386
386
  }
387
- function alignment(attributes) {
388
- const format = scalarString(property(attributes, 'format'))?.toLowerCase() ?? '';
387
+ function alignment(attributes, buttonText) {
388
+ const explicitFormat = scalarString(property(attributes, 'format'))?.toLowerCase();
389
+ const format = explicitFormat ?? '';
389
390
  const horizontalAlignment = format.includes('center')
390
391
  ? 'center'
391
392
  : format.includes('right')
392
393
  ? 'right'
393
- : 'left';
394
+ : buttonText && explicitFormat === undefined
395
+ ? 'center'
396
+ : 'left';
394
397
  const verticalAlignment = format.includes('bottom')
395
398
  ? 'bottom'
396
399
  : format.includes('center')
397
400
  ? 'center'
398
- : 'top';
401
+ : buttonText && explicitFormat === undefined
402
+ ? 'center'
403
+ : 'top';
399
404
  return { horizontalAlignment, verticalAlignment };
400
405
  }
401
406
  class GuiSceneWorkBudget {
@@ -523,7 +528,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
523
528
  }
524
529
  }
525
530
  }
526
- const rawText = scalarString(property(definition.attributes, 'text', 'buttonText'));
531
+ const rawButtonText = scalarString(property(definition.attributes, 'buttonText'));
532
+ const rawText = scalarString(property(definition.attributes, 'text')) ?? rawButtonText;
527
533
  let text;
528
534
  if (rawText !== undefined) {
529
535
  const resolved = resolveTokenText(rawText, scenario, context.localisation, rowValues);
@@ -568,7 +574,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
568
574
  measuredWidth,
569
575
  measuredHeight,
570
576
  metricSource: wrapped.metricSource,
571
- ...alignment(definition.attributes),
577
+ ...alignment(definition.attributes, rawButtonText !== undefined),
572
578
  ...(fontName === undefined ? {} : { fontName }),
573
579
  glyphLines,
574
580
  overflowX: width > 0 && measuredWidth > width + 0.01,
@@ -4,6 +4,7 @@ import { ServiceError } from '../core/result.js';
4
4
  import { GUI_SCENARIO_MAX_KEYS, GUI_SCENARIO_MAX_LIST_KEYS, GUI_SCENARIO_MAX_ROWS, GUI_SCENARIO_MAX_STRING_CHARACTERS, } from './limits.js';
5
5
  const scalarSchema = z.union([z.string().max(16_384), z.number(), z.boolean()]);
6
6
  const objectSchema = z.record(z.string(), scalarSchema);
7
+ const elementNameSchema = z.string().min(1).max(256);
7
8
  const previewStateSchema = z.enum([
8
9
  'normal',
9
10
  'hover',
@@ -124,6 +125,15 @@ const GuiPreviewScenarioBodySchema = z
124
125
  scrollOffsets: z.record(z.string(), z.number().min(0)).default({}),
125
126
  guiCosts: z.record(z.string(), z.number()).default({}),
126
127
  scriptCosts: z.record(z.string(), z.number()).default({}),
128
+ expectations: z
129
+ .object({
130
+ visible: z.array(elementNameSchema).max(10_000).default([]),
131
+ hidden: z.array(elementNameSchema).max(10_000).default([]),
132
+ containedBy: z.record(elementNameSchema, elementNameSchema).default({}),
133
+ centeredOn: z.record(elementNameSchema, elementNameSchema).default({}),
134
+ })
135
+ .strict()
136
+ .default({ visible: [], hidden: [], containedBy: {}, centeredOn: {} }),
127
137
  })
128
138
  .strict()
129
139
  .superRefine((scenario, context) => {
@@ -325,6 +325,72 @@ function safeSlug(value) {
325
325
  function scenarioWith(base, patch) {
326
326
  return parsePreviewScenario({ ...base, ...patch });
327
327
  }
328
+ function assertUniqueScenarioIds(scenarios) {
329
+ const seen = new Set();
330
+ for (const scenario of scenarios) {
331
+ if (seen.has(scenario.id)) {
332
+ throw new ServiceError('GUI_SCENARIO_ID_DUPLICATE', `GUI scenario id must be unique within one request: ${scenario.id}`, { scenarioId: scenario.id });
333
+ }
334
+ seen.add(scenario.id);
335
+ }
336
+ }
337
+ function scenarioMatrixEvidence(scenes) {
338
+ const primary = scenes[0];
339
+ if (primary === undefined)
340
+ return { offline: true, scenarios: [], changes: [] };
341
+ const elementKey = (element) => `${element.sourceId}#${element.rowIndex ?? 0}`;
342
+ const primaryByKey = new Map(primary.elements.map((element) => [elementKey(element), element]));
343
+ return {
344
+ offline: true,
345
+ scenarios: scenes.map((scene) => ({
346
+ scenario: scene.scenario,
347
+ sourceRevision: scene.sourceRevision,
348
+ fidelity: scene.fidelity,
349
+ visibleElements: scene.elements
350
+ .filter(({ visible }) => visible)
351
+ .map(({ id, name }) => ({ id, name })),
352
+ })),
353
+ changes: scenes.slice(1).map((scene) => {
354
+ const currentByKey = new Map(scene.elements.map((element) => [elementKey(element), element]));
355
+ const keys = [...new Set([...primaryByKey.keys(), ...currentByKey.keys()])].sort((left, right) => compareCodeUnits(left, right));
356
+ const shown = [];
357
+ const hidden = [];
358
+ const moved = [];
359
+ const resized = [];
360
+ const textChanged = [];
361
+ const frameChanged = [];
362
+ for (const key of keys) {
363
+ const before = primaryByKey.get(key);
364
+ const after = currentByKey.get(key);
365
+ if (before?.visible !== true && after?.visible === true)
366
+ shown.push(after.name);
367
+ if (before?.visible === true && after?.visible !== true)
368
+ hidden.push(before.name);
369
+ if (before === undefined || after === undefined)
370
+ continue;
371
+ if (before.unclippedRect.x !== after.unclippedRect.x ||
372
+ before.unclippedRect.y !== after.unclippedRect.y)
373
+ moved.push(after.name);
374
+ if (before.unclippedRect.width !== after.unclippedRect.width ||
375
+ before.unclippedRect.height !== after.unclippedRect.height)
376
+ resized.push(after.name);
377
+ if (before.text?.text !== after.text?.text)
378
+ textChanged.push(after.name);
379
+ if (before.sprite?.frame !== after.sprite?.frame)
380
+ frameChanged.push(after.name);
381
+ }
382
+ return {
383
+ scenarioId: scene.scenario.id,
384
+ shown,
385
+ hidden,
386
+ moved,
387
+ resized,
388
+ textChanged,
389
+ frameChanged,
390
+ };
391
+ }),
392
+ };
393
+ }
328
394
  function fullImage(render) {
329
395
  const image = render.images.find(({ variant }) => variant === 'full') ?? render.images[0];
330
396
  if (image === undefined)
@@ -460,6 +526,7 @@ export class ScriptedGuiStudio {
460
526
  input.signal?.throwIfAborted();
461
527
  const scenario = parsePreviewScenario(input.scenario);
462
528
  const relatedScenarios = (input.relatedScenarios ?? []).map(parsePreviewScenario);
529
+ assertUniqueScenarioIds([scenario, ...relatedScenarios]);
463
530
  const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [scenario.language, ...relatedScenarios.map(({ language }) => language)]);
464
531
  const budget = new RenderBudget();
465
532
  const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget);
@@ -510,11 +577,37 @@ export class ScriptedGuiStudio {
510
577
  const budget = new RenderBudget();
511
578
  const workspace = this.resolver.get(input.workspaceId, input.principal);
512
579
  const scenario = parsePreviewScenario(input.scenario);
580
+ const relatedScenarios = (input.relatedScenarios ?? []).map(parsePreviewScenario);
581
+ assertUniqueScenarioIds([scenario, ...relatedScenarios]);
513
582
  const baselineScenario = parsePreviewScenario(input.comparisonScenario ?? { ...scenario, id: `${scenario.id}-comparison`, state: 'normal' });
514
- const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [scenario.language, baselineScenario.language]);
583
+ const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [
584
+ scenario.language,
585
+ baselineScenario.language,
586
+ ...relatedScenarios.map(({ language }) => language),
587
+ ]);
515
588
  const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget);
516
589
  const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
517
590
  const render = await renderGuiScene(scene, undefined, input.signal, budget);
591
+ const scenarioScenes = [scene];
592
+ const scenarioItems = [];
593
+ for (const variantScenario of [scenario, ...relatedScenarios]) {
594
+ input.signal?.throwIfAborted();
595
+ const variantScene = variantScenario.id === scenario.id
596
+ ? scene
597
+ : await buildGuiScene(scanned.graph, scanned.files, input.windowName, variantScenario, catalog);
598
+ if (variantScene !== scene)
599
+ scenarioScenes.push(variantScene);
600
+ const variantRender = variantScene === scene
601
+ ? render
602
+ : await renderGuiScene(variantScene, ['full'], input.signal, new RenderBudget());
603
+ const image = fullImage(variantRender);
604
+ scenarioItems.push({
605
+ label: variantScenario.description ?? variantScenario.id,
606
+ png: image.png,
607
+ width: image.width,
608
+ height: image.height,
609
+ });
610
+ }
518
611
  const stateScenes = [];
519
612
  const stateItems = [];
520
613
  for (const state of input.states ?? [...galleryStates]) {
@@ -556,21 +649,29 @@ export class ScriptedGuiStudio {
556
649
  const baselineScene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, baselineScenario, catalog);
557
650
  const baselineRender = await renderGuiScene(baselineScene, ['full'], input.signal, budget);
558
651
  const comparison = await compareGuiImages(fullImage(baselineRender).png, fullImage(render).png, budget, input.signal);
559
- const validation = await validateGuiScene(scanned.graph, scene, scanned.files, resolutionScenes, catalog);
652
+ const validation = await validateGuiScene(scanned.graph, scene, scanned.files, scenarioScenes.slice(1), catalog);
560
653
  const stateValidation = validateStateMatrix(stateScenes);
561
654
  const resolutionValidation = validateResolutionDrift(resolutionScenes);
562
655
  validation.diagnostics.push(...stateValidation.diagnostics, ...resolutionValidation.diagnostics);
563
656
  validation.checks.push(...stateValidation.checks, ...resolutionValidation.checks);
564
657
  const completeValidation = withGraphDiagnostics(scanned.graph, validation);
658
+ const scenarioGallerySvg = renderGallerySvg(`${input.windowName} scripted scenario matrix`, scenarioItems);
565
659
  const stateGallerySvg = renderGallerySvg(`${input.windowName} state matrix`, stateItems);
566
660
  const resolutionGallerySvg = renderGallerySvg(`${input.windowName} resolution and UI-scale matrix`, resolutionItems);
661
+ const scenarioGallerySize = galleryDimensions(scenarioItems);
567
662
  const stateGallerySize = galleryDimensions(stateItems);
568
663
  const resolutionGallerySize = galleryDimensions(resolutionItems);
569
- budget.reserve(stateGallerySize.width, stateGallerySize.height, 'GUI state gallery PNG');
570
- budget.reserve(resolutionGallerySize.width, resolutionGallerySize.height, 'GUI resolution gallery PNG');
571
- budget.reserveRasterOperation(`gui-gallery:${sha256Bytes(stateGallerySvg)}`, 'GUI state gallery SVG rasterization');
572
- budget.reserveRasterOperation(`gui-gallery:${sha256Bytes(resolutionGallerySvg)}`, 'GUI resolution gallery SVG rasterization');
573
- const [stateGalleryPng, resolutionGalleryPng] = await Promise.all([
664
+ const galleryBudget = new RenderBudget();
665
+ galleryBudget.reserve(scenarioGallerySize.width, scenarioGallerySize.height, 'GUI scripted scenario gallery PNG');
666
+ galleryBudget.reserve(stateGallerySize.width, stateGallerySize.height, 'GUI state gallery PNG');
667
+ galleryBudget.reserve(resolutionGallerySize.width, resolutionGallerySize.height, 'GUI resolution gallery PNG');
668
+ galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(scenarioGallerySvg)}`, 'GUI scripted scenario gallery SVG rasterization');
669
+ galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(stateGallerySvg)}`, 'GUI state gallery SVG rasterization');
670
+ galleryBudget.reserveRasterOperation(`gui-gallery:${sha256Bytes(resolutionGallerySvg)}`, 'GUI resolution gallery SVG rasterization');
671
+ const [scenarioGalleryPng, stateGalleryPng, resolutionGalleryPng] = await Promise.all([
672
+ sharp(Buffer.from(scenarioGallerySvg), { limitInputPixels: RENDER_MAX_PIXELS })
673
+ .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
674
+ .toBuffer(),
574
675
  sharp(Buffer.from(stateGallerySvg), { limitInputPixels: RENDER_MAX_PIXELS })
575
676
  .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
576
677
  .toBuffer(),
@@ -598,7 +699,10 @@ export class ScriptedGuiStudio {
598
699
  add(`${slug}-scenario.json`, 'application/json', render.scenarioJson, 'gui-preview-scenario');
599
700
  add(`${slug}-fidelity.json`, 'application/json', `${canonicalJson({ offline: true, fidelity: render.fidelity })}\n`, 'gui-fidelity-report');
600
701
  add(`${slug}-source-graph.json`, 'application/json', `${canonicalJson(scanned.graph)}\n`, 'gui-source-graph');
601
- add(`${slug}-validation.json`, 'application/json', `${canonicalJson(completeValidation)}\n`, 'gui-validation-report', [scene, ...stateScenes, ...resolutionScenes]);
702
+ add(`${slug}-validation.json`, 'application/json', `${canonicalJson(completeValidation)}\n`, 'gui-validation-report', [...scenarioScenes, ...stateScenes, ...resolutionScenes]);
703
+ add(`${slug}-scenario-matrix.svg`, 'image/svg+xml', scenarioGallerySvg, 'gui-scripted-scenario-matrix', scenarioScenes);
704
+ add(`${slug}-scenario-matrix.png`, 'image/png', scenarioGalleryPng, 'gui-scripted-scenario-matrix', scenarioScenes);
705
+ add(`${slug}-scenario-matrix.json`, 'application/json', `${canonicalJson(scenarioMatrixEvidence(scenarioScenes))}\n`, 'gui-scripted-scenario-matrix-json', scenarioScenes);
602
706
  add(`${slug}-state-matrix.svg`, 'image/svg+xml', stateGallerySvg, 'gui-state-matrix', stateScenes);
603
707
  add(`${slug}-state-matrix.png`, 'image/png', stateGalleryPng, 'gui-state-matrix', stateScenes);
604
708
  add(`${slug}-state-matrix.json`, 'application/json', `${canonicalJson({ offline: true, scenarios: stateScenes.map(({ scenario: stateScenario, fidelity }) => ({ scenario: stateScenario, fidelity })) })}\n`, 'gui-state-matrix-json', stateScenes);
@@ -615,6 +719,7 @@ export class ScriptedGuiStudio {
615
719
  artifacts: stored,
616
720
  filesScanned: scanned.graph.filesScanned,
617
721
  render,
722
+ scenarioScenes,
618
723
  stateScenes,
619
724
  resolutionScenes,
620
725
  comparison,
@@ -96,6 +96,177 @@ function contains(outer, inner, tolerance = 0.01) {
96
96
  inner.x + inner.width <= outer.x + outer.width + tolerance &&
97
97
  inner.y + inner.height <= outer.y + outer.height + tolerance);
98
98
  }
99
+ function visualTextBounds(element) {
100
+ const text = element.text;
101
+ if (text === undefined)
102
+ return element.unclippedRect;
103
+ const width = Math.min(element.unclippedRect.width, text.measuredWidth);
104
+ const height = Math.min(element.unclippedRect.height, text.measuredHeight);
105
+ const x = text.horizontalAlignment === 'center'
106
+ ? element.unclippedRect.x + (element.unclippedRect.width - width) / 2
107
+ : text.horizontalAlignment === 'right'
108
+ ? element.unclippedRect.x + element.unclippedRect.width - width
109
+ : element.unclippedRect.x;
110
+ const y = text.verticalAlignment === 'center'
111
+ ? element.unclippedRect.y + (element.unclippedRect.height - height) / 2
112
+ : text.verticalAlignment === 'bottom'
113
+ ? element.unclippedRect.y + element.unclippedRect.height - height
114
+ : element.unclippedRect.y;
115
+ return { x, y, width, height };
116
+ }
117
+ function centreDelta(inner, outer) {
118
+ return {
119
+ x: inner.x + inner.width / 2 - (outer.x + outer.width / 2),
120
+ y: inner.y + inner.height / 2 - (outer.y + outer.height / 2),
121
+ };
122
+ }
123
+ function elementMatches(element, selector) {
124
+ return element.name === selector || element.id === selector || element.sourceId === selector;
125
+ }
126
+ function expectedElements(scene, selector) {
127
+ return scene.elements.filter((element) => elementMatches(element, selector));
128
+ }
129
+ function validateScenarioExpectations(scene, diagnostics) {
130
+ const { expectations } = scene.scenario;
131
+ const missing = new Set();
132
+ const requireElements = (selector) => {
133
+ const matches = expectedElements(scene, selector);
134
+ if (matches.length === 0 && !missing.has(selector)) {
135
+ missing.add(selector);
136
+ diagnostics.push({
137
+ code: 'GUI_EXPECTED_ELEMENT_MISSING',
138
+ severity: 'error',
139
+ category: 'design',
140
+ message: `Scenario ${scene.scenario.id} expects GUI element ${selector}, but the window does not contain it.`,
141
+ details: { scenarioId: scene.scenario.id, element: selector },
142
+ });
143
+ }
144
+ return matches;
145
+ };
146
+ for (const selector of expectations.visible) {
147
+ const matches = requireElements(selector);
148
+ if (matches.length > 0 && !matches.some(({ visible }) => visible))
149
+ diagnostics.push({
150
+ code: 'GUI_EXPECTED_ELEMENT_HIDDEN',
151
+ severity: 'error',
152
+ category: 'design',
153
+ message: `Scenario ${scene.scenario.id} expects ${selector} to be visible, but every matching element is hidden.`,
154
+ details: { scenarioId: scene.scenario.id, element: selector },
155
+ });
156
+ }
157
+ for (const selector of expectations.hidden) {
158
+ const matches = requireElements(selector);
159
+ if (matches.some(({ visible }) => visible))
160
+ diagnostics.push({
161
+ code: 'GUI_EXPECTED_ELEMENT_VISIBLE',
162
+ severity: 'error',
163
+ category: 'design',
164
+ message: `Scenario ${scene.scenario.id} expects ${selector} to be hidden, but a matching element is visible.`,
165
+ details: { scenarioId: scene.scenario.id, element: selector },
166
+ });
167
+ }
168
+ for (const [selector, ownerSelector] of Object.entries(expectations.containedBy)) {
169
+ const elements = requireElements(selector).filter(({ visible }) => visible);
170
+ const owners = requireElements(ownerSelector).filter(({ visible }) => visible);
171
+ for (const element of elements) {
172
+ if (owners.some((owner) => contains(owner.unclippedRect, element.unclippedRect, 0.5)))
173
+ continue;
174
+ diagnostics.push(issue('GUI_CONTENT_OUTSIDE_EXPECTED_BACKGROUND', 'error', 'layout', `Scenario ${scene.scenario.id} places ${selector} outside its expected background or panel ${ownerSelector}.`, element, {
175
+ scenarioId: scene.scenario.id,
176
+ element: selector,
177
+ expectedContainer: ownerSelector,
178
+ }));
179
+ }
180
+ }
181
+ for (const [selector, ownerSelector] of Object.entries(expectations.centeredOn)) {
182
+ const elements = requireElements(selector).filter(({ visible }) => visible);
183
+ const owners = requireElements(ownerSelector).filter(({ visible }) => visible);
184
+ for (const element of elements) {
185
+ const target = owners
186
+ .filter((owner) => intersection(owner.unclippedRect, element.unclippedRect) !== undefined)
187
+ .toSorted((left, right) => area(left.unclippedRect) - area(right.unclippedRect))[0];
188
+ if (target === undefined) {
189
+ diagnostics.push(issue('GUI_EXPECTED_CENTER_TARGET_MISSING', 'error', 'layout', `Scenario ${scene.scenario.id} cannot center ${selector} because it does not overlap visible target ${ownerSelector}.`, element, { scenarioId: scene.scenario.id, element: selector, target: ownerSelector }));
190
+ continue;
191
+ }
192
+ const delta = centreDelta(visualTextBounds(element), target.unclippedRect);
193
+ const toleranceX = Math.max(2, target.unclippedRect.width * 0.025);
194
+ const toleranceY = Math.max(2, target.unclippedRect.height * 0.08);
195
+ if (Math.abs(delta.x) <= toleranceX && Math.abs(delta.y) <= toleranceY)
196
+ continue;
197
+ diagnostics.push(issue('GUI_EXPECTED_CENTERING_MISMATCH', 'error', 'layout', `Scenario ${scene.scenario.id} offsets ${selector} from the center of ${ownerSelector} by ${delta.x.toFixed(2)}px horizontally and ${delta.y.toFixed(2)}px vertically.`, element, {
198
+ scenarioId: scene.scenario.id,
199
+ element: selector,
200
+ target: ownerSelector,
201
+ deltaX: delta.x,
202
+ deltaY: delta.y,
203
+ }));
204
+ }
205
+ }
206
+ }
207
+ function validateButtonLabelsAndBackgrounds(scene, diagnostics) {
208
+ const visible = scene.elements.filter((element) => element.visible);
209
+ const byId = new Map(scene.elements.map((element) => [element.id, element]));
210
+ const buttons = visible.filter((element) => element.clickable && /button/iu.test(element.elementType));
211
+ const labels = visible.filter((element) => element.text !== undefined);
212
+ const inferredButtonByLabel = new Map();
213
+ const inferredLabelCountByButton = new Map();
214
+ for (const label of labels) {
215
+ if (buttons.some(({ id }) => id === label.id))
216
+ continue;
217
+ const parentButton = label.parentId === undefined ? undefined : buttons.find(({ id }) => id === label.parentId);
218
+ const overlayButton = buttons
219
+ .filter((button) => {
220
+ if (button.zIndex > label.zIndex || button.id === label.id)
221
+ return false;
222
+ const overlap = intersection(button.unclippedRect, label.unclippedRect);
223
+ return (overlap !== undefined && area(overlap) / Math.max(1, area(label.unclippedRect)) >= 0.6);
224
+ })
225
+ .toSorted((left, right) => area(left.unclippedRect) - area(right.unclippedRect))[0];
226
+ const inferredButton = parentButton ?? overlayButton;
227
+ if (inferredButton === undefined)
228
+ continue;
229
+ inferredButtonByLabel.set(label.id, inferredButton);
230
+ inferredLabelCountByButton.set(inferredButton.id, (inferredLabelCountByButton.get(inferredButton.id) ?? 0) + 1);
231
+ }
232
+ for (const label of labels) {
233
+ const ownButton = buttons.find(({ id }) => id === label.id);
234
+ const inferredButton = inferredButtonByLabel.get(label.id);
235
+ const button = ownButton ??
236
+ (inferredButton !== undefined && inferredLabelCountByButton.get(inferredButton.id) === 1
237
+ ? inferredButton
238
+ : undefined);
239
+ if (button !== undefined) {
240
+ const delta = centreDelta(visualTextBounds(label), button.unclippedRect);
241
+ const toleranceX = Math.max(2, button.unclippedRect.width * 0.025);
242
+ const toleranceY = Math.max(2, button.unclippedRect.height * 0.08);
243
+ if (Math.abs(delta.x) > toleranceX || Math.abs(delta.y) > toleranceY)
244
+ diagnostics.push(issue('GUI_BUTTON_LABEL_OFF_CENTER', 'warning', 'layout', `${label.name} is offset from button ${button.name} by ${delta.x.toFixed(2)}px horizontally and ${delta.y.toFixed(2)}px vertically in scenario ${scene.scenario.id}.`, label, {
245
+ scenarioId: scene.scenario.id,
246
+ label: label.name,
247
+ button: button.name,
248
+ deltaX: delta.x,
249
+ deltaY: delta.y,
250
+ }));
251
+ }
252
+ let parent = label.parentId === undefined ? undefined : byId.get(label.parentId);
253
+ while (parent !== undefined && parent.sprite === undefined)
254
+ parent = parent.parentId === undefined ? undefined : byId.get(parent.parentId);
255
+ if (parent !== undefined &&
256
+ !contains(parent.unclippedRect, label.unclippedRect, 0.5) &&
257
+ intersection(parent.unclippedRect, label.unclippedRect) !== undefined)
258
+ diagnostics.push(issue('GUI_CONTENT_CROSSES_BACKGROUND_EDGE', 'warning', 'layout', `${label.name} crosses the visible background boundary of ${parent.name} in scenario ${scene.scenario.id}.`, label, { scenarioId: scene.scenario.id, element: label.name, background: parent.name }));
259
+ }
260
+ for (const control of visible.filter((element) => element.clickable && element.text === undefined)) {
261
+ let parent = control.parentId === undefined ? undefined : byId.get(control.parentId);
262
+ while (parent !== undefined && parent.sprite === undefined)
263
+ parent = parent.parentId === undefined ? undefined : byId.get(parent.parentId);
264
+ if (parent !== undefined &&
265
+ !contains(parent.unclippedRect, control.unclippedRect, 0.5) &&
266
+ intersection(parent.unclippedRect, control.unclippedRect) !== undefined)
267
+ diagnostics.push(issue('GUI_CONTENT_CROSSES_BACKGROUND_EDGE', 'warning', 'layout', `${control.name} crosses the visible background boundary of ${parent.name} in scenario ${scene.scenario.id}.`, control, { scenarioId: scene.scenario.id, element: control.name, background: parent.name }));
268
+ }
269
+ }
99
270
  function sameRect(left, right) {
100
271
  return (Math.abs(left.x - right.x) < 0.01 &&
101
272
  Math.abs(left.y - right.y) < 0.01 &&
@@ -900,19 +1071,27 @@ export async function validateGuiScene(graph, scene, files, relatedScenes = [],
900
1071
  };
901
1072
  }
902
1073
  const diagnostics = validationCollector();
903
- const work = new GuiValidationWorkBudget();
904
- diagnostics.pushMany(scene.diagnostics);
905
- validateOverlapAndGeometry(scene, diagnostics, work);
906
- validateAlignmentAndSpacing(scene, diagnostics, work);
907
- validateReferencesAndScript(graph, scene, diagnostics);
908
- validateScrollAndTabs(scene, diagnostics);
1074
+ for (const currentScene of [scene, ...relatedScenes]) {
1075
+ const sceneDiagnostics = validationCollector();
1076
+ const work = new GuiValidationWorkBudget();
1077
+ sceneDiagnostics.pushMany(currentScene.diagnostics);
1078
+ validateOverlapAndGeometry(currentScene, sceneDiagnostics, work);
1079
+ validateAlignmentAndSpacing(currentScene, sceneDiagnostics, work);
1080
+ validateButtonLabelsAndBackgrounds(currentScene, sceneDiagnostics);
1081
+ validateScenarioExpectations(currentScene, sceneDiagnostics);
1082
+ validateReferencesAndScript(graph, currentScene, sceneDiagnostics);
1083
+ validateScrollAndTabs(currentScene, sceneDiagnostics);
1084
+ diagnostics.pushMany(sceneDiagnostics.values().map((diagnostic) => ({
1085
+ ...diagnostic,
1086
+ details: { ...diagnostic.details, scenarioId: currentScene.scenario.id },
1087
+ })));
1088
+ }
909
1089
  const selectedSpriteNames = new Set();
910
- for (const element of scene.elements)
911
- if (element.sprite !== undefined)
912
- selectedSpriteNames.add(element.sprite.spriteName);
1090
+ for (const currentScene of [scene, ...relatedScenes])
1091
+ for (const element of currentScene.elements)
1092
+ if (element.sprite !== undefined)
1093
+ selectedSpriteNames.add(element.sprite.spriteName);
913
1094
  await validateGuiAnimationsInto(graph, files, selectedSpriteNames, catalog, diagnostics);
914
- if (relatedScenes.length > 0)
915
- validateResolutionDriftInto([scene, ...relatedScenes], diagnostics);
916
1095
  return validationResult(diagnostics.values(), [
917
1096
  'GUI_VALIDATION_DIAGNOSTICS_TRUNCATED',
918
1097
  'GUI_VALIDATION_COMPARISON_BUDGET_BLOCKED',
@@ -922,6 +1101,14 @@ export async function validateGuiScene(graph, scene, files, relatedScenes = [],
922
1101
  'GUI_TEXT_OVERFLOW',
923
1102
  'GUI_INCONSISTENT_ALIGNMENT',
924
1103
  'GUI_INCONSISTENT_SPACING',
1104
+ 'GUI_BUTTON_LABEL_OFF_CENTER',
1105
+ 'GUI_CONTENT_CROSSES_BACKGROUND_EDGE',
1106
+ 'GUI_EXPECTED_ELEMENT_MISSING',
1107
+ 'GUI_EXPECTED_ELEMENT_HIDDEN',
1108
+ 'GUI_EXPECTED_ELEMENT_VISIBLE',
1109
+ 'GUI_CONTENT_OUTSIDE_EXPECTED_BACKGROUND',
1110
+ 'GUI_EXPECTED_CENTER_TARGET_MISSING',
1111
+ 'GUI_EXPECTED_CENTERING_MISMATCH',
925
1112
  'GUI_INVALID_SIZE',
926
1113
  'GUI_CHILD_OUTSIDE_CLIPPED_PARENT',
927
1114
  'GUI_CLICK_BOUNDS_MISMATCH',
@@ -31,6 +31,11 @@ const focusInspectOutputSchema = strictOperationResultSchema(z
31
31
  branchCount: nonNegativeIntegerSchema,
32
32
  continuousPaletteCount: nonNegativeIntegerSchema,
33
33
  continuousFocusCount: nonNegativeIntegerSchema,
34
+ continuousFocusPosition: z
35
+ .object({ x: z.number().int(), y: z.number().int() })
36
+ .strict()
37
+ .nullable(),
38
+ continuousFocusPaletteIds: z.array(z.string().max(256)).max(100),
34
39
  resolvedTitleCount: nonNegativeIntegerSchema,
35
40
  layoutHash: sha256Schema,
36
41
  layoutDecisionCount: nonNegativeIntegerSchema,
@@ -601,7 +606,7 @@ export function registerFocusTools(server, engine, context) {
601
606
  const workbench = new FocusWorkbench(engine.resolver, engine.transactions, engine.artifacts);
602
607
  server.registerTool('hoi4.focus_inspect', {
603
608
  title: 'Inspect focus trees',
604
- description: 'Inspect national trees or continuous palettes for creation and cleanup, including complete plans, references, diagnostics, and stable layout decisions.',
609
+ description: 'Inspect national trees or continuous palettes for creation and cleanup, including continuous-focus placement, complete plans, references, diagnostics, and stable layout decisions.',
605
610
  inputSchema: focusInspectInput,
606
611
  outputSchema: focusInspectOutputSchema,
607
612
  annotations: artifactProducing,
@@ -791,6 +796,8 @@ export function registerFocusTools(server, engine, context) {
791
796
  branchCount: plan.branchGroups.length,
792
797
  continuousPaletteCount: plan.continuousFocusPaletteIds.length,
793
798
  continuousFocusCount: plan.continuousFocusIds.length,
799
+ continuousFocusPosition: plan.continuousFocusPosition ?? null,
800
+ continuousFocusPaletteIds: plan.continuousFocusPaletteIds.slice(0, 100),
794
801
  resolvedTitleCount: plan.focuses.filter(({ id }) => presentation.entries[id]?.titleSourceLocation !== undefined).length,
795
802
  layoutHash: layout.layoutHash,
796
803
  layoutDecisionCount: layout.decisions.length,