hoi4-agent-tools 3.5.1 → 3.7.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 (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +137 -116
  3. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  4. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  5. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  6. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  7. package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
  8. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  9. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  10. package/dist/hoi4_agent_tools/core/scanner.js +133 -47
  11. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  12. package/dist/hoi4_agent_tools/gui/layout.js +237 -11
  13. package/dist/hoi4_agent_tools/gui/renderer.js +36 -3
  14. package/dist/hoi4_agent_tools/gui/scenario.js +18 -0
  15. package/dist/hoi4_agent_tools/gui/studio.js +73 -21
  16. package/dist/hoi4_agent_tools/map/service.js +2 -2
  17. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  18. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  19. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  20. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  21. package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
  22. package/dist/hoi4_agent_tools/reference/service.js +139 -67
  23. package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
  24. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  25. package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
  26. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  27. package/dist/hoi4_agent_tools/version.js +1 -1
  28. package/docs/README.md +34 -18
  29. package/docs/examples.md +75 -0
  30. package/docs/gui.md +23 -0
  31. package/docs/reference.md +43 -2
  32. package/docs/research/reference-benchmark.md +41 -0
  33. package/docs/setup.md +50 -26
  34. package/docs/tools.md +65 -0
  35. package/npm-shrinkwrap.json +7088 -0
  36. package/package.json +10 -7
  37. package/schemas/configuration.schema.json +1 -1
  38. package/schemas/continuous-focus-palette.schema.json +1 -1
  39. package/schemas/custom-weighted-pool.schema.json +1 -1
  40. package/schemas/decision-inspect-input.schema.json +1 -1
  41. package/schemas/event-feature-manifest.schema.json +1 -1
  42. package/schemas/focus-plan.schema.json +1 -1
  43. package/schemas/focus-planning-sidecar.schema.json +1 -1
  44. package/schemas/gui-animation-source.schema.json +1 -1
  45. package/schemas/gui-helper.schema.json +1 -1
  46. package/schemas/gui-scenario.schema.json +139 -89
  47. package/schemas/helper-expansion-request.schema.json +1 -1
  48. package/schemas/helper-expansion-summary.schema.json +1 -1
  49. package/schemas/impact-inspect-input.schema.json +1 -1
  50. package/schemas/map-operation.schema.json +1 -1
  51. package/schemas/mechanic-test-input.schema.json +1 -1
  52. package/schemas/operation-result.schema.json +1 -1
  53. package/schemas/package-check-input.schema.json +1 -1
  54. package/schemas/package-manifest.schema.json +1 -1
  55. package/schemas/probability-analysis-result.schema.json +1 -1
  56. package/schemas/probability-compare-input.schema.json +1 -1
  57. package/schemas/probability-evaluate-input.schema.json +1 -1
  58. package/schemas/probability-inspect-input.schema.json +1 -1
  59. package/schemas/probability-render-input.schema.json +1 -1
  60. package/schemas/probability-scenario-set.schema.json +1 -1
  61. package/schemas/probability-sequence-input.schema.json +1 -1
  62. package/schemas/probability-simulate-input.schema.json +1 -1
  63. package/schemas/probability-sweep-input.schema.json +1 -1
  64. package/schemas/reference-context-input.schema.json +1 -1
  65. package/schemas/reference-read-input.schema.json +9 -1
  66. package/schemas/reference-search-input.schema.json +1 -1
  67. package/schemas/scenario-suite.schema.json +1 -1
  68. package/schemas/scenario-test-input.schema.json +1 -1
  69. package/schemas/script-validate-input.schema.json +92 -0
  70. package/schemas/shared-scenario.schema.json +1 -1
  71. package/schemas/source-lookup-input.schema.json +68 -2
  72. package/server.json +3 -3
@@ -5,7 +5,7 @@ import { ServiceError } from '../core/result.js';
5
5
  import { GuiAssetCatalog } from './assets.js';
6
6
  import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES, GUI_SCENE_MAX_DEPTH, GUI_SCENE_MAX_ELEMENTS, GUI_SCENE_MAX_TEXT_CHARACTERS, GUI_SCENE_MAX_TEXT_LAYOUT_OPERATIONS, GUI_SCENE_MAX_WORK, GUI_TEXT_MAX_CHARACTERS, GUI_TEXT_MAX_MISSING_GLYPH_SAMPLES, } from './limits.js';
7
7
  import { guiElementAttributeFidelity } from './source-graph.js';
8
- import { emptyFidelityReport } from './types.js';
8
+ import { emptyFidelityReport, fidelityCategories } from './types.js';
9
9
  import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
10
10
  import { evaluateGuiCondition, guiConditionScenario } from './scenario-conditions.js';
11
11
  import { parseClausewitz } from '../core/source/index.js';
@@ -636,13 +636,29 @@ function refreshSceneClips(context) {
636
636
  : geometry.appearanceVisible
637
637
  ? 'outside_clip'
638
638
  : (element.visibilityReason ?? 'visibility_false');
639
+ element.visibilityStatus =
640
+ intersection === undefined
641
+ ? 'hidden'
642
+ : element.visibilityStatusReason !== undefined
643
+ ? 'unresolved'
644
+ : element.visible
645
+ ? 'shown'
646
+ : 'hidden';
639
647
  if (clickableTypes.test(element.elementType)) {
640
648
  const reason = element.visible ? geometry.interactionBlock : 'hidden';
641
649
  if (reason === undefined)
642
650
  delete element.disabledReason;
643
651
  else
644
652
  element.disabledReason = reason;
645
- element.clickable = reason === undefined;
653
+ element.enablement =
654
+ geometry.interactionBlock !== undefined
655
+ ? 'disabled'
656
+ : element.visibilityStatus === 'unresolved' || element.enablementReason !== undefined
657
+ ? 'unresolved'
658
+ : reason === undefined
659
+ ? 'enabled'
660
+ : 'disabled';
661
+ element.clickable = element.enablement === 'enabled';
646
662
  }
647
663
  }
648
664
  }
@@ -1142,7 +1158,7 @@ async function layoutAttachedScrollbars(definition, scrollbars, owner, childOutp
1142
1158
  vertical.rect.y = Math.max(verticalRect.y, horizontalRect.y + horizontalRect.height);
1143
1159
  vertical.rect.height = Math.max(0, verticalRect.y + verticalRect.height - vertical.rect.y);
1144
1160
  }
1145
- addFidelity(context.fidelity, 'modelled', 'orthogonal_scrollbar_gutters', 'Visible horizontal and vertical scrollbars reserve their anchored cross-axis gutters instead of drawing their end buttons over the same corner.', definition);
1161
+ addFidelity(context.fidelity, 'approximated', 'orthogonal_scrollbar_gutters', 'Visible horizontal and vertical scrollbars reserve their anchored cross-axis gutters instead of drawing their end buttons over the same corner.', definition);
1146
1162
  }
1147
1163
  for (const bar of barLayouts) {
1148
1164
  await layoutElement({
@@ -1232,6 +1248,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1232
1248
  if (height < 0)
1233
1249
  height = Math.max(0, parentRect.height - localY + height);
1234
1250
  const explicitlyVisible = explicitElementVisibility(definition, scenario, rowValues);
1251
+ const unresolvedVisibilityReason = context.unresolvedElementVisibility.get(definition.name);
1252
+ const parentSceneElement = parentInstanceId === undefined ? undefined : context.instancesById.get(parentInstanceId);
1253
+ const parentUnresolvedVisibility = parentSceneElement?.visibilityStatus === 'unresolved'
1254
+ ? parentSceneElement.visibilityStatusReason
1255
+ : undefined;
1235
1256
  const declaredHidden = scalarBoolean(property(definition.attributes, 'hide', 'hidden')) ?? false;
1236
1257
  const sourceVisible = scalarBoolean(property(definition.attributes, 'visible')) ?? !declaredHidden;
1237
1258
  const resolveVisibleAppearance = inheritedVisible && (explicitlyVisible ?? sourceVisible);
@@ -1240,6 +1261,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1240
1261
  scenario.scriptedGui[`${definition.name}.enabled`] ??
1241
1262
  context.constantElementEnabled[definition.name] ??
1242
1263
  scalarBoolean(property(definition.attributes, 'enabled'));
1264
+ const unresolvedEnabledReason = context.unresolvedElementEnabled.get(definition.name);
1265
+ const explicitEnabled = rowValues?.[`${definition.name}.enabled`] ??
1266
+ scenario.values[`${definition.name}.enabled`] ??
1267
+ scenario.scriptedGui[`${definition.name}.enabled`];
1268
+ const enablementUnresolved = unresolvedEnabledReason !== undefined && typeof explicitEnabled !== 'boolean';
1243
1269
  const requestedState = scenario.elementStates[definition.name] ?? scenario.state;
1244
1270
  const state = scriptedEnabled === false ? 'disabled' : requestedState;
1245
1271
  const backgroundValue = property(definition.attributes, 'background');
@@ -1595,6 +1621,12 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1595
1621
  const clipped = clipRect !== undefined &&
1596
1622
  (availableClip === undefined || !equalRect(unclippedRect, availableClip));
1597
1623
  const visible = resolveVisibleAppearance && (availableClip !== undefined || inheritedClip === undefined);
1624
+ const visibilityStatusReason = explicitlyVisible === false
1625
+ ? undefined
1626
+ : (parentUnresolvedVisibility ??
1627
+ (inheritedVisible && explicitlyVisible === undefined
1628
+ ? unresolvedVisibilityReason
1629
+ : undefined));
1598
1630
  const clickThrough = scalarBoolean(property(definition.attributes, 'clickThrough', 'alwaystransparent', 'allwaystransparent')) ?? false;
1599
1631
  const interactive = clickableTypes.test(definition.elementType);
1600
1632
  const interactionBlock = !interactive
@@ -1607,7 +1639,21 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1607
1639
  ? 'scenario_state'
1608
1640
  : undefined;
1609
1641
  const disabledReason = interactive && !visible ? 'hidden' : interactionBlock;
1610
- const clickable = interactive && disabledReason === undefined;
1642
+ const visibilityStatus = availableClip === undefined && inheritedClip !== undefined
1643
+ ? 'hidden'
1644
+ : visibilityStatusReason !== undefined
1645
+ ? 'unresolved'
1646
+ : visible
1647
+ ? 'shown'
1648
+ : 'hidden';
1649
+ const enablement = interactionBlock !== undefined
1650
+ ? 'disabled'
1651
+ : visibilityStatus === 'unresolved' || enablementUnresolved
1652
+ ? 'unresolved'
1653
+ : disabledReason === undefined
1654
+ ? 'enabled'
1655
+ : 'disabled';
1656
+ const clickable = interactive && enablement === 'enabled';
1611
1657
  const visibilityReason = visible
1612
1658
  ? 'visible'
1613
1659
  : !inheritedVisible
@@ -1661,7 +1707,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1661
1707
  zIndex: context.output.length,
1662
1708
  visible,
1663
1709
  visibilityReason,
1710
+ visibilityStatus,
1711
+ ...(visibilityStatusReason === undefined ? {} : { visibilityStatusReason }),
1664
1712
  clickable,
1713
+ ...(interactive ? { enablement } : {}),
1714
+ ...(interactive && enablementUnresolved ? { enablementReason: unresolvedEnabledReason } : {}),
1665
1715
  ...(disabledReason === undefined ? {} : { disabledReason }),
1666
1716
  clickThrough,
1667
1717
  rect: availableClip ?? { x: unclippedRect.x, y: unclippedRect.y, width: 0, height: 0 },
@@ -1977,6 +2027,8 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
1977
2027
  const scriptedWindowVisibility = {};
1978
2028
  const constantElementVisibility = {};
1979
2029
  const constantElementEnabled = {};
2030
+ const unresolvedElementEnabled = new Map();
2031
+ const unresolvedElementVisibility = new Map();
1980
2032
  const resolvedScriptedProperties = {};
1981
2033
  const dynamicListsByName = new Map();
1982
2034
  const countryFlagProperties = new Map();
@@ -2033,11 +2085,25 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2033
2085
  ...scenario.variables,
2034
2086
  ...scenario.values,
2035
2087
  });
2036
- if (resolved === undefined)
2037
- continue;
2038
2088
  const key = `${propertyDefinition.elementName}.${suffix}`;
2039
- if (scenario.values[key] === undefined)
2040
- resolvedScriptedProperties[key] = resolved;
2089
+ if (suffix === 'visible' || suffix === 'enabled') {
2090
+ const booleanValue = scalarBoolean(resolved ?? expression);
2091
+ if (booleanValue === undefined) {
2092
+ const reason = `${scripted.name}.${key}: scenario has no boolean value for ${typeof expression === 'string' ? expression : JSON.stringify(expression)}.`;
2093
+ (suffix === 'visible' ? unresolvedElementVisibility : unresolvedElementEnabled).set(propertyDefinition.elementName, reason);
2094
+ addFidelity(fidelity, 'unresolved', 'scripted_gui_scenario_property', reason);
2095
+ continue;
2096
+ }
2097
+ (suffix === 'visible' ? unresolvedElementVisibility : unresolvedElementEnabled).delete(propertyDefinition.elementName);
2098
+ if (scenario.values[key] === undefined)
2099
+ resolvedScriptedProperties[key] = booleanValue;
2100
+ }
2101
+ else {
2102
+ if (resolved === undefined)
2103
+ continue;
2104
+ if (scenario.values[key] === undefined)
2105
+ resolvedScriptedProperties[key] = resolved;
2106
+ }
2041
2107
  addFidelity(fidelity, 'modelled', 'scripted_gui_scenario_property', `${key} resolved from a scenario runtime value.`);
2042
2108
  }
2043
2109
  }
@@ -2051,13 +2117,23 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2051
2117
  ? undefined
2052
2118
  : evaluated.state === 'true';
2053
2119
  if (result === undefined) {
2054
- addFidelity(fidelity, 'unresolved', 'scripted_gui_trigger', `${trigger.name} requires more scenario facts.`);
2120
+ if (trigger.name.endsWith('_visible'))
2121
+ unresolvedElementVisibility.set(trigger.elementName, `${scripted.name}.${trigger.name}: ${evaluated?.unresolved.map(({ message }) => message).join('; ') ?? 'condition requires more scenario facts'}`);
2122
+ if (trigger.name.endsWith('_click_enabled'))
2123
+ unresolvedElementEnabled.set(trigger.elementName, `${scripted.name}.${trigger.name}: ${evaluated?.unresolved.map(({ message }) => message).join('; ') ?? 'condition requires more scenario facts'}`);
2124
+ addFidelity(fidelity, 'unresolved', 'scripted_gui_trigger', unresolvedElementVisibility.get(trigger.elementName) ??
2125
+ unresolvedElementEnabled.get(trigger.elementName) ??
2126
+ `${scripted.name}.${trigger.name} requires more scenario facts.`);
2055
2127
  continue;
2056
2128
  }
2057
2129
  if (trigger.name.endsWith('_visible'))
2058
2130
  constantElementVisibility[trigger.elementName] = result;
2131
+ if (trigger.name.endsWith('_visible'))
2132
+ unresolvedElementVisibility.delete(trigger.elementName);
2059
2133
  if (trigger.name.endsWith('_click_enabled'))
2060
2134
  constantElementEnabled[trigger.elementName] = result;
2135
+ if (trigger.name.endsWith('_click_enabled'))
2136
+ unresolvedElementEnabled.delete(trigger.elementName);
2061
2137
  addFidelity(fidelity, 'modelled', trigger.constantResult === undefined
2062
2138
  ? 'scripted_gui_trigger'
2063
2139
  : 'scripted_gui_constant_trigger', `${trigger.name} resolves to ${result ? 'yes' : 'no'}.`);
@@ -2117,6 +2193,8 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2117
2193
  dynamicListsByName,
2118
2194
  countryFlagProperties,
2119
2195
  constantElementEnabled,
2196
+ unresolvedElementEnabled,
2197
+ unresolvedElementVisibility,
2120
2198
  spritesByName: new Map(graph.sprites.map((sprite) => [sprite.name.toLowerCase(), sprite])),
2121
2199
  localisation,
2122
2200
  output,
@@ -2171,11 +2249,158 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2171
2249
  });
2172
2250
  }
2173
2251
  refreshSceneClips(context);
2252
+ let tooltipPreview;
2253
+ if (scenario.tooltip !== undefined) {
2254
+ const { target: targetToken, x, y, width, padding } = scenario.tooltip;
2255
+ const textWidth = width - 2 * padding;
2256
+ const skipTooltip = (code, message) => {
2257
+ diagnostics.push({ code, severity: 'warning', category: 'rendering', message });
2258
+ addFidelity(fidelity, 'unresolved', 'immediate_tooltip_preview', message);
2259
+ };
2260
+ const matches = output.filter(({ id, name }) => id === targetToken || name === targetToken);
2261
+ const target = matches[0];
2262
+ if (matches.length === 0)
2263
+ skipTooltip('GUI_TOOLTIP_TARGET_MISSING', `Tooltip target ${targetToken} is absent from the scene.`);
2264
+ else if (matches.length > 1)
2265
+ skipTooltip('GUI_TOOLTIP_TARGET_AMBIGUOUS', `Tooltip target ${targetToken} matches ${matches.length} scene elements; select an instance id.`);
2266
+ else if (target?.visibilityStatus === 'unresolved')
2267
+ skipTooltip('GUI_TOOLTIP_TARGET_UNRESOLVED', `Tooltip target ${targetToken} has unresolved visibility: ${target.visibilityStatusReason ?? 'more scenario facts are required'}.`);
2268
+ else if (target?.visible !== true)
2269
+ skipTooltip('GUI_TOOLTIP_TARGET_HIDDEN', `Tooltip target ${targetToken} is hidden in this preview.`);
2270
+ else {
2271
+ const source = elementsById.get(target.sourceId);
2272
+ const localisationKey = source === undefined ? undefined : scalarString(property(source.attributes, 'pdx_tooltip'));
2273
+ const localised = localisationKey === undefined
2274
+ ? undefined
2275
+ : (scenario.localisation[localisationKey] ?? localisation.get(localisationKey));
2276
+ const tooltipSprite = graph.sprites.find(({ name }) => name.toLowerCase() === 'tooltip');
2277
+ const tooltipFont = catalog.fontDefinition('cg_16b');
2278
+ if (localisationKey === undefined)
2279
+ skipTooltip('GUI_TOOLTIP_KEY_MISSING', `Tooltip target ${targetToken} has no pdx_tooltip localisation key.`);
2280
+ else if (localised === undefined)
2281
+ skipTooltip('GUI_TOOLTIP_LOCALISATION_MISSING', `Tooltip localisation ${localisationKey} is unavailable in ${scenario.language}.`);
2282
+ else if (tooltipSprite === undefined)
2283
+ skipTooltip('GUI_TOOLTIP_SPRITE_MISSING', 'The declared ToolTip text sprite is unavailable.');
2284
+ else if (tooltipFont === undefined ||
2285
+ catalog.resolvedFontMetrics('cg_16b').source === 'approximation')
2286
+ skipTooltip('GUI_TOOLTIP_FONT_MISSING', 'The declared cg_16b tooltip font or its metrics are unavailable.');
2287
+ else if (x + width > scenario.resolution.width || y >= scenario.resolution.height)
2288
+ skipTooltip('GUI_TOOLTIP_BOUNDS', 'Caller-declared tooltip position and width exceed the viewport.');
2289
+ else {
2290
+ const spriteFrame = await catalog.loadSpriteFrame(tooltipSprite, 0);
2291
+ if (spriteFrame?.supported !== true || spriteFrame.dataUri === undefined)
2292
+ skipTooltip('GUI_TOOLTIP_SPRITE_MISSING', spriteFrame?.reason ?? 'The ToolTip texture is unavailable.');
2293
+ else {
2294
+ const missingEngineText = [];
2295
+ const suppliedText = replaceTextBounded(localised, /\[![^\]]+\]/gu, (match) => {
2296
+ const token = match[0];
2297
+ const supplied = scenario.values[token] ?? scenario.scriptedGui[token];
2298
+ if (typeof supplied === 'string' && supplied.length > 0)
2299
+ return supplied;
2300
+ missingEngineText.push(token);
2301
+ return token;
2302
+ }, 'GUI tooltip supplied effect text');
2303
+ const resolved = resolveTokenText(suppliedText, scenario, localisation);
2304
+ const unresolved = [
2305
+ ...new Set([
2306
+ ...missingEngineText,
2307
+ ...resolved.unresolved,
2308
+ ...[...resolved.text.matchAll(/\[![^\]]+\]/gu)].map(([token]) => token),
2309
+ ]),
2310
+ ];
2311
+ if (unresolved.length > 0)
2312
+ skipTooltip('GUI_TOOLTIP_TEXT_UNRESOLVED', `Tooltip ${localisationKey} requires supplied values for ${unresolved.join(', ')}.`);
2313
+ else {
2314
+ const tooltipTextDefinition = {
2315
+ id: `tooltip-preview:${target.id}:text`,
2316
+ name: `${target.name}_immediate_tooltip_text`,
2317
+ elementType: 'instantTextBoxType',
2318
+ sourcePath: source?.sourcePath ?? target.sourcePath,
2319
+ childIds: [],
2320
+ attributes: {
2321
+ position: { x: (x + padding) / baseScale, y: (y + padding) / baseScale },
2322
+ size: { width: textWidth / baseScale, height: 0 },
2323
+ text: suppliedText,
2324
+ font: 'cg_16b',
2325
+ multiline: true,
2326
+ fixedsize: true,
2327
+ },
2328
+ unsupportedAttributes: [],
2329
+ rawSource: '',
2330
+ definitionOrder: source?.definitionOrder ?? 0,
2331
+ };
2332
+ const tooltipContext = {
2333
+ ...context,
2334
+ output: [],
2335
+ instancesById: new Map(),
2336
+ geometry: new Map(),
2337
+ fidelity: emptyFidelityReport(),
2338
+ diagnostics: [],
2339
+ work: new GuiSceneWorkBudget(),
2340
+ };
2341
+ await layoutElement(tooltipTextDefinition, viewport, viewport, baseScale, 0, tooltipContext);
2342
+ refreshSceneClips(tooltipContext);
2343
+ const textElement = tooltipContext.output[0];
2344
+ const popupText = textElement?.text;
2345
+ if (textElement === undefined ||
2346
+ popupText === undefined ||
2347
+ popupText.unresolvedTokens.length > 0 ||
2348
+ popupText.metricSource === 'approximation' ||
2349
+ popupText.glyphLines.some(({ source: glyphSource, missingGlyphs }) => glyphSource === 'deterministic-fallback' || missingGlyphs.length > 0) ||
2350
+ popupText.inlineIcons?.some(({ sprite }) => sprite?.supported !== true || sprite.dataUri === undefined))
2351
+ skipTooltip('GUI_TOOLTIP_ASSET_OR_TEXT_UNRESOLVED', `Tooltip ${localisationKey} has unresolved text, glyph, icon, or texture data.`);
2352
+ else if (popupText.overflowX ||
2353
+ popupText.overflowY ||
2354
+ y + popupText.measuredHeight + 2 * padding > scenario.resolution.height)
2355
+ skipTooltip('GUI_TOOLTIP_BOUNDS', 'Tooltip text does not fit the caller-declared inset or the padded layout exceeds the viewport.');
2356
+ else {
2357
+ const backgroundDefinition = {
2358
+ ...tooltipTextDefinition,
2359
+ id: `tooltip-preview:${target.id}`,
2360
+ name: `${target.name}_immediate_tooltip`,
2361
+ elementType: 'iconType',
2362
+ attributes: {
2363
+ position: { x: x / baseScale, y: y / baseScale },
2364
+ size: {
2365
+ width: width / baseScale,
2366
+ height: (popupText.measuredHeight + 2 * padding) / baseScale,
2367
+ },
2368
+ spriteType: tooltipSprite.name,
2369
+ },
2370
+ };
2371
+ await layoutElement(backgroundDefinition, viewport, viewport, baseScale, 0, tooltipContext);
2372
+ const backgroundElement = tooltipContext.instancesById.get(backgroundDefinition.id);
2373
+ backgroundElement.zIndex = 0;
2374
+ textElement.zIndex = 1;
2375
+ textElement.depth = 1;
2376
+ textElement.parentId = backgroundElement.id;
2377
+ tooltipPreview = {
2378
+ targetId: target.id,
2379
+ localisationKey,
2380
+ placement: 'caller-declared',
2381
+ element: backgroundElement,
2382
+ textElement,
2383
+ };
2384
+ for (const category of fidelityCategories)
2385
+ fidelity[category].push(...tooltipContext.fidelity[category]);
2386
+ fidelity.ignored = fidelity.ignored.filter(({ field, elementId }) => field !== 'pdx_tooltip' || elementId !== target.sourceId);
2387
+ addFidelity(fidelity, 'approximated', 'immediate_tooltip_preview', `Immediate tooltip ${localisationKey} uses caller-declared viewport position, width, and ${padding}px padding with ToolTip and cg_16b; native placement, padding, and timing remain unverified.`);
2388
+ addFidelity(fidelity, 'approximated', 'tooltip_text_sprite_composition', 'ToolTip textSpriteType texture is stretched to the measured text height plus caller-declared padding; native frame composition is unverified.');
2389
+ addFidelity(fidelity, 'unsupported', 'pdx_tooltip_delayed', 'Delayed tooltip timing is not previewed.');
2390
+ addFidelity(fidelity, 'unsupported', 'click_to_front', 'Tooltip preview does not model click_to_front behavior.');
2391
+ }
2392
+ }
2393
+ }
2394
+ }
2395
+ }
2396
+ }
2174
2397
  output.sort((left, right) => left.zIndex - right.zIndex || compareCodeUnits(left.id, right.id));
2175
- const bounds = unionRects(output.flatMap((element) => {
2398
+ const bounds = unionRects([...output, ...(tooltipPreview === undefined ? [] : [tooltipPreview.element])].flatMap((element) => {
2176
2399
  if (!element.visible)
2177
2400
  return [];
2178
- const painted = element.sprite === undefined && element.progressColours === undefined ? [] : [element.rect];
2401
+ const painted = element.sprite === undefined && element.progressColours === undefined
2402
+ ? []
2403
+ : [element.rect];
2179
2404
  const text = element.text;
2180
2405
  if (text !== undefined && text.text.length > 0) {
2181
2406
  const rect = element.unclippedRect;
@@ -2209,6 +2434,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2209
2434
  scenario,
2210
2435
  resolution: scenario.resolution,
2211
2436
  elements: output,
2437
+ ...(tooltipPreview === undefined ? {} : { tooltipPreview }),
2212
2438
  bounds,
2213
2439
  fidelity,
2214
2440
  diagnostics: sortDiagnostics(diagnostics),
@@ -39,7 +39,10 @@ function bitmapDefinitionId(key, layer = 'face') {
39
39
  function sceneGlyphDefinitions(scene) {
40
40
  const outlines = new Map();
41
41
  const bitmaps = new Map();
42
- for (const element of scene.elements) {
42
+ for (const element of [
43
+ ...scene.elements,
44
+ ...(scene.tooltipPreview === undefined ? [] : [scene.tooltipPreview.textElement]),
45
+ ]) {
43
46
  for (const line of element.text?.glyphLines ?? []) {
44
47
  for (const glyph of line.glyphs) {
45
48
  if (glyph.kind === 'outline')
@@ -400,6 +403,8 @@ function renderOverlay(element, variant, toolText) {
400
403
  if (rect.width <= 0 || rect.height <= 0)
401
404
  return '';
402
405
  if (variant === 'click-regions') {
406
+ if (element.enablement === 'unresolved')
407
+ return `<g><rect ${rectAttributes(rect)} fill="#ffb84d" fill-opacity="0.12" stroke="#ffb84d" stroke-width="2" stroke-dasharray="5 3"/><title>${escapeXml(`Potential click region: ${element.enablementReason ?? element.visibilityStatusReason ?? element.name}`)}</title>${toolText.render(`${element.name} (?)`, { x: rect.x + 3, y: rect.y + 13, fontSize: 11, fill: '#ffb84d' })}</g>`;
403
408
  if (!element.clickable)
404
409
  return '';
405
410
  return `<g><rect ${rectAttributes(rect)} fill="#00d4ff" fill-opacity="0.2" stroke="#00d4ff" stroke-width="2"/>${toolText.render(element.name, { x: rect.x + 3, y: rect.y + 13, fontSize: 11, fill: '#00d4ff' })}</g>`;
@@ -418,6 +423,13 @@ function renderOverlay(element, variant, toolText) {
418
423
  }
419
424
  return '';
420
425
  }
426
+ function renderTooltipPreview(scene, toolText) {
427
+ const tooltip = scene.tooltipPreview;
428
+ if (tooltip === undefined)
429
+ return '';
430
+ return (renderBaseElement(tooltip.element, undefined, toolText) +
431
+ renderBaseElement(tooltip.textElement, undefined, toolText));
432
+ }
421
433
  function viewFor(scene, variant) {
422
434
  if (variant !== 'cropped' || scene.bounds.width <= 0 || scene.bounds.height <= 0) {
423
435
  return {
@@ -453,7 +465,7 @@ export function sceneToSvg(scene, variant) {
453
465
  : [`<clipPath id="clip-${index}"><rect ${rectAttributes(element.clipRect)}/></clipPath>`]);
454
466
  const body = scene.elements
455
467
  .map((element, index) => renderBaseElement(element, element.clipRect === undefined ? undefined : `clip-${index}`, toolText))
456
- .join('');
468
+ .join('') + renderTooltipPreview(scene, toolText);
457
469
  const overlays = scene.elements
458
470
  .map((element) => renderOverlay(element, variant, toolText))
459
471
  .join('');
@@ -479,7 +491,7 @@ async function sceneToSvgCooperative(scene, variant, signal) {
479
491
  const clipDefinitions = await cooperativeParts(scene.elements, (element, index) => element.clipRect === undefined
480
492
  ? ''
481
493
  : `<clipPath id="clip-${index}"><rect ${rectAttributes(element.clipRect)}/></clipPath>`, signal);
482
- const body = await cooperativeParts(scene.elements, (element, index) => renderBaseElement(element, element.clipRect === undefined ? undefined : `clip-${index}`, toolText), signal);
494
+ const body = (await cooperativeParts(scene.elements, (element, index) => renderBaseElement(element, element.clipRect === undefined ? undefined : `clip-${index}`, toolText), signal)) + renderTooltipPreview(scene, toolText);
483
495
  const overlays = await cooperativeParts(scene.elements, (element) => renderOverlay(element, variant, toolText), signal);
484
496
  const fidelity = escapeXml(canonicalJson(scene.fidelity));
485
497
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" viewBox="${finite(view.viewBox.x)} ${finite(view.viewBox.y)} ${finite(view.viewBox.width)} ${finite(view.viewBox.height)}"><metadata data-renderer="hoi4-agent-tools" data-mode="offline">${fidelity}</metadata><defs>${clipDefinitions}${sceneGlyphDefinitions(scene)}${toolText.definitions()}</defs><rect ${rectAttributes(view.viewBox)} fill="#17202a"/>${body}${overlays}</svg>`;
@@ -526,6 +538,27 @@ export function hierarchyToSvg(scene) {
526
538
  function sceneLayoutEvidence(scene) {
527
539
  return {
528
540
  ...scene,
541
+ ...(scene.tooltipPreview === undefined
542
+ ? {}
543
+ : {
544
+ tooltipPreview: {
545
+ targetId: scene.tooltipPreview.targetId,
546
+ localisationKey: scene.tooltipPreview.localisationKey,
547
+ placement: scene.tooltipPreview.placement,
548
+ element: {
549
+ id: scene.tooltipPreview.element.id,
550
+ rect: scene.tooltipPreview.element.rect,
551
+ sprite: scene.tooltipPreview.element.sprite?.spriteName,
552
+ },
553
+ textElement: {
554
+ id: scene.tooltipPreview.textElement.id,
555
+ rect: scene.tooltipPreview.textElement.rect,
556
+ text: scene.tooltipPreview.textElement.text?.text,
557
+ lines: scene.tooltipPreview.textElement.text?.lines,
558
+ fontName: scene.tooltipPreview.textElement.text?.fontName,
559
+ },
560
+ },
561
+ }),
529
562
  elements: scene.elements.map((element) => ({
530
563
  ...element,
531
564
  ...(element.sprite === undefined
@@ -103,6 +103,24 @@ const GuiPreviewScenarioBodySchema = z
103
103
  .min(1)
104
104
  .max(128)
105
105
  .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
106
+ tooltip: z
107
+ .object({
108
+ target: elementNameSchema,
109
+ x: z.number().nonnegative(),
110
+ y: z.number().nonnegative(),
111
+ width: z.number().positive().max(RENDER_MAX_DIMENSION),
112
+ padding: z
113
+ .number()
114
+ .nonnegative()
115
+ .max(RENDER_MAX_DIMENSION / 2)
116
+ .describe('Caller-declared inset on each side, in viewport pixels; no native padding is inferred.'),
117
+ })
118
+ .strict()
119
+ .refine(({ padding, width }) => padding * 2 < width, {
120
+ path: ['padding'],
121
+ message: 'Tooltip padding must leave a positive text width.',
122
+ })
123
+ .optional(),
106
124
  description: z.string().max(500).optional(),
107
125
  date: GuiScenarioDateSchema.optional(),
108
126
  controls: ConditionControlMapSchema.optional(),