hoi4-agent-tools 3.4.1 → 3.5.1

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 (96) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +26 -18
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +41 -2
  4. package/dist/hoi4_agent_tools/core/condition-schema.js +18 -0
  5. package/dist/hoi4_agent_tools/core/configuration.js +10 -0
  6. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +9 -1
  7. package/dist/hoi4_agent_tools/core/impact-semantic.js +11 -4
  8. package/dist/hoi4_agent_tools/core/scanner.js +1 -1
  9. package/dist/hoi4_agent_tools/core/scenario-model.js +6 -0
  10. package/dist/hoi4_agent_tools/core/scenario-state.js +3 -0
  11. package/dist/hoi4_agent_tools/core/workspace.js +13 -1
  12. package/dist/hoi4_agent_tools/event/graph.js +8 -1
  13. package/dist/hoi4_agent_tools/event/service.js +1 -0
  14. package/dist/hoi4_agent_tools/event/source-analysis.js +1 -1
  15. package/dist/hoi4_agent_tools/focus/render.js +10 -2
  16. package/dist/hoi4_agent_tools/gui/layout.js +58 -11
  17. package/dist/hoi4_agent_tools/gui/scenario.js +3 -1
  18. package/dist/hoi4_agent_tools/gui/source-graph.js +4 -1
  19. package/dist/hoi4_agent_tools/gui/studio.js +32 -10
  20. package/dist/hoi4_agent_tools/map/model.js +121 -12
  21. package/dist/hoi4_agent_tools/map/operations.js +80 -7
  22. package/dist/hoi4_agent_tools/map/service.js +1 -0
  23. package/dist/hoi4_agent_tools/map/validation.js +38 -1
  24. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +18 -1
  25. package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
  26. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  27. package/dist/hoi4_agent_tools/mcp/tools/reference.js +60 -0
  28. package/dist/hoi4_agent_tools/probability/service.js +2 -2
  29. package/dist/hoi4_agent_tools/probability/source-analysis.js +16 -0
  30. package/dist/hoi4_agent_tools/probability/value-evaluator.js +2 -1
  31. package/dist/hoi4_agent_tools/reference/lines.js +52 -0
  32. package/dist/hoi4_agent_tools/reference/operations.js +41 -0
  33. package/dist/hoi4_agent_tools/reference/service.js +444 -0
  34. package/dist/hoi4_agent_tools/reference/source-lookup.js +90 -0
  35. package/dist/hoi4_agent_tools/schemas/analysis.js +2 -0
  36. package/dist/hoi4_agent_tools/schemas/gui-requests.js +1 -1
  37. package/dist/hoi4_agent_tools/schemas/probability-requests.js +1 -1
  38. package/dist/hoi4_agent_tools/schemas/probability.js +26 -2
  39. package/dist/hoi4_agent_tools/schemas/reference.js +161 -0
  40. package/dist/hoi4_agent_tools/technology/render.js +9 -4
  41. package/dist/hoi4_agent_tools/version.js +1 -1
  42. package/docs/README.md +3 -0
  43. package/docs/gui.md +6 -0
  44. package/docs/map.md +8 -0
  45. package/docs/probability.md +7 -0
  46. package/docs/reference.md +32 -0
  47. package/docs/research/reference-benchmark.md +42 -0
  48. package/docs/setup.md +6 -0
  49. package/examples/clients/codex-config.toml +2 -0
  50. package/package.json +4 -2
  51. package/schemas/configuration.schema.json +72 -58
  52. package/schemas/continuous-focus-palette.schema.json +1 -1
  53. package/schemas/custom-weighted-pool.schema.json +1 -1
  54. package/schemas/decision-inspect-input.schema.json +43 -26
  55. package/schemas/event-feature-manifest.schema.json +1 -1
  56. package/schemas/focus-plan.schema.json +1 -1
  57. package/schemas/focus-planning-sidecar.schema.json +1 -1
  58. package/schemas/gui-animation-source.schema.json +1 -1
  59. package/schemas/gui-helper.schema.json +1 -1
  60. package/schemas/gui-scenario.schema.json +102 -75
  61. package/schemas/helper-expansion-request.schema.json +1 -1
  62. package/schemas/helper-expansion-summary.schema.json +1 -1
  63. package/schemas/impact-inspect-input.schema.json +1 -1
  64. package/schemas/map-operation.schema.json +1 -1
  65. package/schemas/mechanic-test-input.schema.json +49 -32
  66. package/schemas/operation-result.schema.json +1 -1
  67. package/schemas/package-check-input.schema.json +1 -1
  68. package/schemas/package-manifest.schema.json +1 -1
  69. package/schemas/probability-analysis-result.schema.json +1 -1
  70. package/schemas/probability-compare-input.schema.json +160 -131
  71. package/schemas/probability-evaluate-input.schema.json +161 -132
  72. package/schemas/probability-inspect-input.schema.json +20 -11
  73. package/schemas/probability-render-input.schema.json +1 -1
  74. package/schemas/probability-scenario-set.schema.json +110 -90
  75. package/schemas/probability-sequence-input.schema.json +154 -134
  76. package/schemas/probability-simulate-input.schema.json +161 -132
  77. package/schemas/probability-sweep-input.schema.json +163 -134
  78. package/schemas/reference-context-input.schema.json +42 -0
  79. package/schemas/reference-read-input.schema.json +41 -0
  80. package/schemas/reference-search-input.schema.json +45 -0
  81. package/schemas/scenario-suite.schema.json +92 -75
  82. package/schemas/scenario-test-input.schema.json +103 -86
  83. package/schemas/shared-scenario.schema.json +39 -22
  84. package/schemas/source-lookup-input.schema.json +61 -0
  85. package/server.json +2 -2
  86. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  87. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  88. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  89. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  90. package/docs/images/readme/event-chain-mcp.png +0 -0
  91. package/docs/images/readme/focus-tree-mcp.png +0 -0
  92. package/docs/images/readme/map-mcp.png +0 -0
  93. package/docs/images/readme/probability-mcp.png +0 -0
  94. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  95. package/docs/images/readme/settings-events-mcp.png +0 -0
  96. package/docs/images/readme/technology-tree-mcp.png +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.5.1 - 2026-09-27
6
+
7
+ - Preserve installed-game and offline-wiki citations in compact reference context bundles, select question-relevant sections within required sources, and report sources omitted by the result limit separately from missing files.
8
+ - Exercise one-, two-, and eight-result context budgets across six real-source work surfaces while retaining the nine-query discovery benchmark.
9
+ - Allow up to five minutes for npm registry processing before publication verification fails.
10
+
11
+ ## 3.5.0 - 2026-09-26
12
+
13
+ - Search and read bounded, revision-bound local offline-wiki, installed-game, and optional generated-script documentation sections; retrieve task citation bundles and exact indexed source definitions and usages through four read-only MCP tools.
14
+ - Compare hash-bound frozen probability source files at their logical gameplay paths while preserving physical source provenance and leaving current files untouched.
15
+ - Keep candidate eligibility overrides separate from weight-modifier conditions so an eligible scenario does not force every modifier to apply.
16
+ - Evaluate hidden and custom-tooltip trigger wrappers with their enclosed conditions while excluding presentation metadata from eligibility logic.
17
+ - Accept explicit GUI scenario dates and state-controller facts, evaluate their source conditions, render requested native windows at their declared settled show position, and resolve native list templates and scenario-only localisation from the correct source.
18
+ - Honor single-line native labels and preserve the first inferred font line, with explicit fidelity reporting for undersized native text-height conventions.
19
+ - Preserve dotted event namespaces so a namespace query selects its own events rather than unrelated systems sharing the first name segment.
20
+ - Compact selected event diagrams without retaining empty row gaps from the rest of the workspace graph.
21
+ - Resolve supported dated state-history values at the earliest active bookmark for map inspection, and refuse state rewrites that would silently conflict with or discard dated commands.
22
+ - Interpret floating-harbor placements as sea-side markers with land targets, preserving vanilla-compatible anomalies as source-linked review warnings instead of thousands of false map errors.
23
+ - Show alternatives within a national focus prerequisite group with dotted connectors, leaving individually required prerequisites solid.
24
+ - Draw wide technology-folder icons at their decoded sprite dimensions and the source GUI's icon anchor, preserving 131×52 chemical cylinders instead of shrinking them to 70×70.
25
+ - Include user-approved in-game focus and technology captures beside current-source and source-matched historical MCP renders, with source revisions and explicit whole-image comparison limits.
26
+ - Include native-size vanilla and Chaos Redux interface references with declared scenarios, full images, diagnostics, and source hashes; keep game imagery outside the npm package.
27
+
5
28
  ## 3.4.1 - 2026-09-25
6
29
 
7
30
  - Validate technology renders against the requested view's source placement, node coverage, and sprite resolution without inheriting unrelated graph-wide diagnostics, while reporting deferred whole-workspace helper projection as a separate analysis boundary; resolve subtechnology art from the source GUI slot before using a technology-id fallback and include every referenced slot texture in the asset scan.
@@ -401,4 +424,4 @@
401
424
  - Removed runtime workspace registration, manual rollback, legacy write-policy inputs, and package-level library/schema exports.
402
425
  - Renamed the HTTP discovery grant to `allowDiscoveredMods`; it grants discovered mod IDs only.
403
426
 
404
- Earlier release history is available in the [Git tags](https://github.com/klimPaskov/hoi4-agent-tools/tags).
427
+ Earlier release history is preserved in the [commit history](https://github.com/klimPaskov/hoi4-agent-tools/commits/main).
package/README.md CHANGED
@@ -11,34 +11,35 @@ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron
11
11
  - Cross-system impact and decisions: trace definitions and consumers across source systems, evaluate declared actor and target scenarios, inspect costs and mission paths, and compare in-memory proposals.
12
12
  - Technology trees: reconstruct technology and doctrine paths, folder layouts, unlocks, bonuses, grants, metadata, assets, and structural changes.
13
13
  - AI and MTTH: evaluate weighted choices and timing across explicit scenarios, bind special scope chains, enumerate dynamic target pools, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
14
+ - Local references: search and read cited offline wiki pages, installed game documentation, optional script documentation, and exact source symbols without loading whole manuals into the conversation.
14
15
 
15
16
  ## Real results
16
17
 
17
- The GUI renderer resolves the mod and installed-game sources, fonts, localisation colours, sprites, scripted visibility, dynamic values, lists, controls, and panel composition. These pairs show the in-game surface beside the MCP result.
18
+ The examples use real installed vanilla and Chaos Redux source, supplied game captures, and explicit reproducible display scenarios. They demonstrate source-backed review, not a claim of 99.9% whole-image accuracy. Images, source revisions, hashes, and known differences are in [Visual comparisons](docs/visual-comparisons.md) and [Interface examples](docs/gui-comparisons.md).
18
19
 
19
- | Communist Insurgency in game | Communist Insurgency from MCP |
20
- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
21
- | ![Communist Insurgency in game](docs/images/readme/communist-insurgency-ingame.png) | ![Communist Insurgency rendered by the MCP](docs/images/readme/communist-insurgency-mcp.png) |
20
+ | Options Video in game | Options Video from the MCP renderer |
21
+ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
22
+ | ![Options Video in game](docs/images/comparisons/gui/options-video-ingame.png) | ![Options Video rendered from installed source](docs/images/comparisons/gui/options-video-mcp.png) |
22
23
 
23
- | Chaos Meter in game | Chaos Meter from MCP |
24
- | ----------------------------------------------------------------- | -------------------------------------------------------------------------- |
25
- | ![Chaos Meter in game](docs/images/readme/chaos-meter-ingame.png) | ![Chaos Meter rendered by the MCP](docs/images/readme/chaos-meter-mcp.png) |
24
+ | Škoda Priority in game | Škoda Priority from the MCP renderer |
25
+ | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
26
+ | ![Škoda Priority in game](docs/images/comparisons/gui/skoda-priority-ingame.png) | ![Škoda Priority rendered from installed source](docs/images/comparisons/gui/skoda-priority-mcp.png) |
26
27
 
27
- The same scenario-aware renderer can select mutually exclusive settings panes and populate plausible runtime values.
28
-
29
- | Trigger Events settings | Advanced settings |
30
- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
31
- | ![Trigger Events settings rendered by the MCP](docs/images/readme/settings-events-mcp.png) | ![Advanced settings rendered by the MCP](docs/images/readme/settings-advanced-mcp.png) |
28
+ The interface gallery also includes Chaos Redux Settings, Event Log, and Chaos Meter, plus populated vanilla decision and occupation templates. Their manifests separate source-defined layout from declared native runtime fields.
32
29
 
33
30
  The other workbenches return complete source-linked layouts and catalogs rather than prose-only summaries.
34
31
 
35
- | Focus tree | Technology tree |
36
- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
37
- | ![Large focus tree rendered by the MCP](docs/images/readme/focus-tree-mcp.png) | ![Technology tree rendered by the MCP](docs/images/readme/technology-tree-mcp.png) |
32
+ For the supplied focus and technology game captures beside current-source MCP renders, see [Visual examples and comparison limits](https://github.com/klimPaskov/hoi4-agent-tools/blob/main/docs/visual-comparisons.md). These comparisons do not establish 99.9% whole-image accuracy.
33
+
34
+ | Fury focus tree from Chaos Redux | Infantry technology folder from Chaos Redux and vanilla |
35
+ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
36
+ | ![Fury focus tree rendered from Chaos Redux source](docs/images/comparisons/fury-mcp.png) | ![Infantry technology folder rendered from installed source](docs/images/comparisons/infantry-mcp.png) |
37
+
38
+ | Map | Event options |
39
+ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
40
+ | ![Installed map rendered by the MCP](docs/images/comparisons/source/map-mcp.png) | ![Chaos Redux event chain rendered by the MCP](docs/images/comparisons/source/event-chain-mcp.png) |
38
41
 
39
- | Map | Event chain | Probability ranking |
40
- | --------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
41
- | ![Full map rendered by the MCP](docs/images/readme/map-mcp.png) | ![Event chain rendered by the MCP](docs/images/readme/event-chain-mcp.png) | ![Probability ranking rendered by the MCP](docs/images/readme/probability-mcp.png) |
42
+ These two views retain native output resolution. Their [source manifest](docs/images/comparisons/source/manifest.json) records the map revision and the exact Fury event used for the option view. The event renderer's omitted-node count includes the rest of the workspace graph outside that selection.
42
43
 
43
44
  ## Use from a coding agent
44
45
 
@@ -51,6 +52,8 @@ npm install --global hoi4-agent-tools@latest
51
52
  ```toml
52
53
  [mcp_servers.hoi4_agent_tools]
53
54
  command = "hoi4-agent-tools.cmd"
55
+ startup_timeout_sec = 120
56
+ tool_timeout_sec = 600
54
57
  ```
55
58
 
56
59
  On non-Windows systems, use `hoi4-agent-tools` as the command. Agentic HOI4 repositories can include these steps in their agent template so the coding agent installs and registers the server itself; manual installation is not required in that workflow. Once connected, the server follows the active mod supplied by the client and the agent can call its HOI4 tools directly.
@@ -69,6 +72,10 @@ On non-Windows systems, use `hoi4-agent-tools` as the command. Agentic HOI4 repo
69
72
  | `hoi4.map_inspect` | Search, click, navigate, and inspect the complete rendered map and its linked data. |
70
73
  | `hoi4.map_render` | Render full-map layers, overlays, names, IDs, coordinates, and source-linked catalogs. |
71
74
  | `hoi4.map_rewrite` | Create or update states, provinces, IDs, networks, positions, and connected map data. |
75
+ | `hoi4.reference_context` | Get compact, cited wiki and installed documentation pointers for a modding surface. |
76
+ | `hoi4.reference_search` | Search bounded local documentation sections. |
77
+ | `hoi4.reference_read` | Read one revision-bound section, with line continuation. |
78
+ | `hoi4.source_lookup` | Find exact definitions, overrides, usages, and narrow source blocks. |
72
79
  | `hoi4.event_inspect` | Scan, trace, explain, lint, or assess event chains and their state flow. |
73
80
  | `hoi4.event_render` | Render source-linked event routes, options, timing, state, scope, and unresolved edges. |
74
81
  | `hoi4.event_compare` | Compare event-chain topology and diagnostics between revisions. |
@@ -114,6 +121,7 @@ Ask your agent in normal task language. A typical workflow is inspect, render, r
114
121
  - Mechanics and packages: "Test this transfer's conservation and payment," or "Check every declared package link and resume its named scenario suite." See [Mechanic tests and scenario suites](docs/mechanics.md).
115
122
  - 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).
116
123
  - AI and MTTH: "Compare these focus weights across peace, defensive-war, and low-stability scenarios," or "Show when this MTTH event becomes likely and which unknown inputs control the result." See [AI and MTTH analysis](docs/probability.md).
124
+ - References: "Find the installed effect signature and an exact vanilla example," or "Show the pertinent wiki and installed documentation for this GUI task." See [Local references](docs/reference.md).
117
125
 
118
126
  ## HTTP
119
127
 
@@ -176,8 +176,17 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
176
176
  const malformed = malformedBlock(assignment.value, candidate);
177
177
  if (malformed !== undefined)
178
178
  return malformed;
179
- if (key === 'AND')
180
- return evaluateTriggerBlock(assignment.value, scenario, candidate, definitions, helperStack, scopeContext);
179
+ if (key === 'AND' ||
180
+ key === 'hidden_trigger' ||
181
+ key === 'custom_trigger_tooltip' ||
182
+ key === 'custom_override_tooltip')
183
+ return evaluateTriggerBlock(key === 'custom_trigger_tooltip' || key === 'custom_override_tooltip'
184
+ ? {
185
+ ...assignment.value,
186
+ entries: assignment.value.entries.filter((entry) => entry.type !== 'assignment' ||
187
+ !['tooltip', 'not_tooltip'].includes(entry.key.value)),
188
+ }
189
+ : assignment.value, scenario, candidate, definitions, helperStack, scopeContext);
181
190
  if (key === 'OR') {
182
191
  const children = assignments(assignment.value).map((child) => evaluateAssignment(child, scenario, candidate, definitions, helperStack, scopeContext));
183
192
  return {
@@ -318,6 +327,36 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
318
327
  unresolved: [],
319
328
  };
320
329
  }
330
+ if ((key === 'controls_state' || key === 'is_controlled_by') && scenario.controls !== undefined) {
331
+ const targetScope = resolveScopeContext(scenario, right, scopeContext);
332
+ const stateId = key === 'controls_state'
333
+ ? /^[1-9][0-9]*$/u.test(right)
334
+ ? right
335
+ : targetScope?.binding.id
336
+ : scopeContext.binding.id;
337
+ const country = key === 'controls_state'
338
+ ? scopeContext.expression === 'ROOT'
339
+ ? scenario.actor
340
+ : scopeIdentity(scopeContext)
341
+ : targetScope === undefined
342
+ ? /^[A-Z0-9]{3}$/u.test(right)
343
+ ? right
344
+ : undefined
345
+ : targetScope.expression === 'ROOT' && scenario.actor === undefined
346
+ ? undefined
347
+ : scopeIdentity(targetScope);
348
+ if (stateId === undefined || !/^[1-9][0-9]*$/u.test(stateId) || country === undefined)
349
+ return unresolved(assignment, candidate, `Scenario does not bind the state and country for ${key}`, 'controls');
350
+ const controller = scenario.controls[stateId];
351
+ if (controller === undefined)
352
+ return unresolved(assignment, candidate, `Scenario does not declare the controller of state ${stateId}`, `controls.${stateId}`);
353
+ if (!['=', '!='].includes(assignment.operator.text))
354
+ return unresolved(assignment, candidate, `Unsupported controller comparison ${assignment.operator.text}`);
355
+ return {
356
+ state: compare(controller, country, assignment.operator.text) ? 'true' : 'false',
357
+ unresolved: [],
358
+ };
359
+ }
321
360
  const helper = definitions.scriptedTriggers.get(key);
322
361
  if (helper !== undefined && bool(right) !== undefined) {
323
362
  if (helperStack.includes(key))
@@ -1,4 +1,22 @@
1
1
  import { z } from 'zod';
2
+ export const ConditionControlMapSchema = z
3
+ .record(z.string().regex(/^[1-9][0-9]{0,8}$/u), z.string().regex(/^[A-Z0-9]{3}$/u))
4
+ .refine((value) => Object.keys(value).length <= 2_048, 'Too many declared state controllers');
5
+ export const GuiScenarioDateSchema = z
6
+ .string()
7
+ .max(16)
8
+ .refine((value) => {
9
+ const match = /^(\d{1,4})[.-](\d{1,2})[.-](\d{1,2})$/u.exec(value);
10
+ if (match === null)
11
+ return false;
12
+ const year = Number(match[1]);
13
+ const month = Number(match[2]);
14
+ const day = Number(match[3]);
15
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
16
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
17
+ return year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1];
18
+ }, 'Use a valid YYYY.M.D or YYYY-MM-DD date')
19
+ .transform((value) => value.split(/[.-]/u).map(Number).join('.'));
2
20
  export const ScenarioScalarSchema = z.union([
3
21
  z.string().max(16_384),
4
22
  z.number(),
@@ -96,6 +96,16 @@ export const workspaceRegistrationSchema = z
96
96
  artifactRoot: z.string().min(1).optional(),
97
97
  cacheRoot: z.string().min(1).optional(),
98
98
  fixtureRoot: z.string().min(1).optional(),
99
+ wikiRoot: z
100
+ .string()
101
+ .min(1)
102
+ .refine((value) => path.isAbsolute(value), 'Wiki root must be absolute')
103
+ .optional(),
104
+ scriptDocsRoot: z
105
+ .string()
106
+ .min(1)
107
+ .refine((value) => path.isAbsolute(value), 'Script documentation root must be absolute')
108
+ .optional(),
99
109
  })
100
110
  .strict()
101
111
  .superRefine((value, context) => {
@@ -1,4 +1,5 @@
1
1
  import { compareCodeUnits } from './canonical.js';
2
+ import { ServiceError } from './result.js';
2
3
  function under(roots, glob) {
3
4
  return roots.map((root) => `${root.replaceAll('\\', '/').replace(/\/$/u, '')}/${glob}`);
4
5
  }
@@ -39,7 +40,14 @@ export function focusDomainScanPatterns(workspace) {
39
40
  /** Weighted source surfaces and shared definitions used by probability analysis. */
40
41
  export function probabilityDomainScanPatterns(workspace, sourcePaths = []) {
41
42
  const roots = workspace.registration.roots;
42
- const normalizedSourcePaths = sourcePaths.map((sourcePath) => sourcePath.replace(/^.*?:/u, '').replaceAll('\\', '/').replace(/^\.\//u, ''));
43
+ const normalizedSourcePaths = sourcePaths.map((sourcePath) => {
44
+ const normalized = sourcePath.replaceAll('\\', '/').replace(/^\.\//u, '');
45
+ if (normalized.startsWith('/') ||
46
+ normalized.split('/').includes('..') ||
47
+ /[:*?[\]{}!\0]/u.test(normalized))
48
+ throw new ServiceError('PROBABILITY_SOURCE_PATH_INVALID', 'Probability source paths must be exact workspace-relative files');
49
+ return normalized;
50
+ });
43
51
  if (normalizedSourcePaths.length > 0)
44
52
  return uniquePatterns([...normalizedSourcePaths, ...probabilitySharedDefinitionPatterns()]);
45
53
  return uniquePatterns([
@@ -56,12 +56,18 @@ function stateKeyTarget(assignment, kind) {
56
56
  function normalized(file) {
57
57
  return file.relativePath.replaceAll('\\', '/').toLowerCase();
58
58
  }
59
- function ownerFor(snapshot, file, assignment, ancestors) {
59
+ function ownerFor(snapshot, file, assignment, ancestors, includeOnActions) {
60
60
  if (assignment.value.type !== 'block')
61
61
  return undefined;
62
62
  const key = assignment.key.value;
63
63
  const sourcePath = normalized(file);
64
64
  let owner;
65
+ if (includeOnActions &&
66
+ sourcePath.startsWith('common/on_actions/') &&
67
+ ancestors.length === 1 &&
68
+ ancestors[0] === 'on_actions') {
69
+ return { kind: 'on_action', id: key };
70
+ }
65
71
  if (sourcePath.startsWith('events/') && ancestors.length === 0 && eventKeys.has(key)) {
66
72
  const id = firstScalar(assignment.value, 'id')?.value;
67
73
  if (id !== undefined)
@@ -114,7 +120,7 @@ function staticTarget(value) {
114
120
  !/^(?:ROOT|FROM|PREV|THIS)$/iu.test(value));
115
121
  }
116
122
  /** Extracts typed script edges while preserving unknown dynamic references. */
117
- export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000) {
123
+ export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000, options = {}) {
118
124
  if (!Number.isSafeInteger(maxReferences) || maxReferences < 1 || maxReferences > 1_000_000)
119
125
  throw new RangeError('Semantic reference limit is outside the supported range');
120
126
  const references = [];
@@ -148,7 +154,7 @@ export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000)
148
154
  };
149
155
  const walk = (file, document, block, ancestors, owner) => {
150
156
  for (const assignment of assignments(block)) {
151
- const identified = ownerFor(snapshot, file, assignment, ancestors);
157
+ const identified = ownerFor(snapshot, file, assignment, ancestors, options.includeOnActions === true);
152
158
  const current = identified ?? owner;
153
159
  if (identified?.kind === 'decision' && ancestors.length === 1)
154
160
  emit(file, document, assignment, identified, 'decision_category', ancestors[0], 'decision_category_membership');
@@ -237,7 +243,8 @@ export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000)
237
243
  path.startsWith('common/technologies/') ||
238
244
  path.startsWith('common/scripted_effects/') ||
239
245
  path.startsWith('common/scripted_triggers/') ||
240
- path.startsWith('common/scripted_guis/')))
246
+ path.startsWith('common/scripted_guis/') ||
247
+ (options.includeOnActions === true && path.startsWith('common/on_actions/'))))
241
248
  continue;
242
249
  if (snapshot.index.isSourceSkipped(file.displayPath)) {
243
250
  complete = false;
@@ -180,7 +180,7 @@ export class WorkspaceScanner {
180
180
  return result;
181
181
  }
182
182
  }
183
- async function readBoundedFile(handle, remainingBytes, signal) {
183
+ export async function readBoundedFile(handle, remainingBytes, signal) {
184
184
  const chunks = [];
185
185
  let total = 0;
186
186
  for (;;) {
@@ -34,6 +34,9 @@ export function scenarioFromGui(base, values = base.values) {
34
34
  for (const [key, value] of Object.entries(base.flags))
35
35
  state[`flag.${key}`] = value;
36
36
  const scopes = structuredClone(base.scopes ?? {});
37
+ for (const stateId of Object.keys(base.controls ?? {})) {
38
+ scopes[stateId] ??= { id: stateId, type: 'state', state: {} };
39
+ }
37
40
  for (const [key, value] of Object.entries(state)) {
38
41
  const match = /^(ROOT|THIS|FROM(?:\.FROM)*|PREV)\.(.+)$/u.exec(key);
39
42
  if (match === null)
@@ -49,6 +52,8 @@ export function scenarioFromGui(base, values = base.values) {
49
52
  return {
50
53
  schemaVersion: '1.0',
51
54
  id: base.id,
55
+ ...(base.date === undefined ? {} : { date: base.date }),
56
+ ...(base.controls === undefined ? {} : { controls: { ...base.controls } }),
52
57
  state,
53
58
  scopes,
54
59
  closedFlags: false,
@@ -62,6 +67,7 @@ export function scenarioFromProbability(base) {
62
67
  id: base.id,
63
68
  ...(base.actor === undefined ? {} : { actor: base.actor }),
64
69
  ...(base.date === undefined ? {} : { date: base.date }),
70
+ ...(base.controls === undefined ? {} : { controls: { ...base.controls } }),
65
71
  state: structuredClone(base.state),
66
72
  ...(base.flags === undefined ? {} : { flags: [...base.flags] }),
67
73
  ...(base.eventTargets === undefined ? {} : { eventTargets: { ...base.eventTargets } }),
@@ -42,6 +42,9 @@ export function resolveScopeContext(scenario, expression, current) {
42
42
  const direct = explicitScope(scenario, expression, current);
43
43
  if (direct !== undefined)
44
44
  return direct;
45
+ if (/^[1-9][0-9]*$/u.test(expression) && Object.hasOwn(scenario.controls ?? {}, expression)) {
46
+ return { expression, binding: { id: expression, type: 'state', state: {} }, parent: current };
47
+ }
45
48
  const targetMatch = /^(?:event_target|scope):(.+)$/iu.exec(expression);
46
49
  const targetId = targetMatch === null
47
50
  ? undefined
@@ -488,6 +488,10 @@ export class WorkspaceResolver {
488
488
  const fixtureRoot = registration.fixtureRoot === undefined
489
489
  ? undefined
490
490
  : await canonicalPath(registration.fixtureRoot);
491
+ const wikiRoot = registration.wikiRoot === undefined ? undefined : await canonicalPath(registration.wikiRoot);
492
+ const scriptDocsRoot = registration.scriptDocsRoot === undefined
493
+ ? undefined
494
+ : await canonicalPath(registration.scriptDocsRoot);
491
495
  const generatedAllowed = (candidate, generatedKind) => (registration.kind === 'mod' &&
492
496
  isWithin(path.join(modRoot, '.hoi4-agent', generatedKind), candidate)) ||
493
497
  this.#storageRoots.some((root) => isWithin(root, candidate));
@@ -524,7 +528,13 @@ export class WorkspaceResolver {
524
528
  }
525
529
  }
526
530
  if (this.#serverStateRoot !== undefined &&
527
- [modRoot, ...sourceRoots, artifactRoot, cacheRoot].some((root) => pathsOverlap(this.#serverStateRoot, root))) {
531
+ [
532
+ modRoot,
533
+ ...sourceRoots,
534
+ artifactRoot,
535
+ cacheRoot,
536
+ ...[wikiRoot, scriptDocsRoot].filter((root) => root !== undefined),
537
+ ].some((root) => pathsOverlap(this.#serverStateRoot, root))) {
528
538
  throw new ServiceError('SERVER_STATE_ROOT_OVERLAP', 'Server state root must not overlap a workspace source or generated-storage root', { workspaceId: registration.id });
529
539
  }
530
540
  await mkdir(artifactRoot, { recursive: true });
@@ -595,6 +605,8 @@ export class WorkspaceResolver {
595
605
  artifactRoot,
596
606
  cacheRoot,
597
607
  ...(fixtureRoot === undefined ? {} : { fixtureRoot }),
608
+ ...(wikiRoot === undefined ? {} : { wikiRoot }),
609
+ ...(scriptDocsRoot === undefined ? {} : { scriptDocsRoot }),
598
610
  writeEnabled: registration.kind === 'mod',
599
611
  };
600
612
  const workspaceIdentity = resolvedWorkspaceIdentity(workspaceBase);
@@ -848,7 +848,14 @@ function ensureReferencedNodes(graph) {
848
848
  id: edge.to,
849
849
  kind: 'unresolved',
850
850
  label: `Missing ${isEvent ? 'event' : 'helper'}: ${expression}`,
851
- ...(isEvent ? { eventId: expression, namespace: expression.split('.')[0] } : {}),
851
+ ...(isEvent
852
+ ? {
853
+ eventId: expression,
854
+ namespace: expression.includes('.')
855
+ ? expression.slice(0, expression.lastIndexOf('.'))
856
+ : expression,
857
+ }
858
+ : {}),
852
859
  sourcePath: edge.location.path,
853
860
  location: edge.location,
854
861
  metadata: { expression, unresolvedKind },
@@ -984,6 +984,7 @@ export class EventChainViewer {
984
984
  const renderOptions = {
985
985
  view: input.view,
986
986
  budget,
987
+ compactLayout: input.compactLayout ?? input.selector !== undefined,
987
988
  ...(input.selector === undefined ? {} : { selector: input.selector }),
988
989
  ...(input.direction === undefined ? {} : { direction: input.direction }),
989
990
  ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }),
@@ -450,7 +450,7 @@ class FragmentAnalyzer {
450
450
  kind: 'event',
451
451
  label: id,
452
452
  eventId: id,
453
- namespace: id.includes('.') ? id.slice(0, id.indexOf('.')) : id,
453
+ namespace: id.includes('.') ? id.slice(0, id.lastIndexOf('.')) : id,
454
454
  sourcePath: this.file.displayPath,
455
455
  location: this.location(assignment, id),
456
456
  metadata: {
@@ -204,7 +204,12 @@ function svgDocument(plan, layout, diagnostics, options) {
204
204
  diagnosticMap.set(focusId, group);
205
205
  }
206
206
  const prerequisiteEdges = plan.focuses
207
- .flatMap((focus) => focus.prerequisites.groups.flatMap((group, groupIndex) => group.focusIds.map((parentId) => ({ parentId, childId: focus.id, groupIndex }))))
207
+ .flatMap((focus) => focus.prerequisites.groups.flatMap((group, groupIndex) => group.focusIds.map((parentId) => ({
208
+ parentId,
209
+ childId: focus.id,
210
+ groupIndex,
211
+ alternative: group.focusIds.length > 1,
212
+ }))))
208
213
  .filter(({ parentId, childId }) => pixel.has(parentId) && pixel.has(childId))
209
214
  .sort((left, right) => compareCodeUnits(left.parentId, right.parentId) ||
210
215
  compareCodeUnits(left.childId, right.childId) ||
@@ -239,7 +244,10 @@ function svgDocument(plan, layout, diagnostics, options) {
239
244
  originX: padding - minimumX * horizontal,
240
245
  originY: padding - minimumY * vertical,
241
246
  });
242
- parts.push(`<path d="${focusConnectorSvgPath(curve)}" fill="none" stroke="#8da2bc" stroke-width="2" marker-end="url(#arrow)" data-parent="${escapeXml(edge.parentId)}" data-child="${escapeXml(edge.childId)}" data-prerequisite-group="${edge.groupIndex}"/>`);
247
+ const stroke = edge.alternative
248
+ ? 'stroke-width="2.5" stroke-dasharray="2 4" stroke-linecap="round"'
249
+ : 'stroke-width="2"';
250
+ parts.push(`<path d="${focusConnectorSvgPath(curve)}" fill="none" stroke="#8da2bc" ${stroke} marker-end="url(#arrow)" data-parent="${escapeXml(edge.parentId)}" data-child="${escapeXml(edge.childId)}" data-prerequisite-group="${edge.groupIndex}" data-prerequisite-kind="${edge.alternative ? 'or' : 'required'}"/>`);
243
251
  }
244
252
  parts.push('</g>', '<g id="mutual-exclusions">');
245
253
  for (const [leftId, rightId] of exclusionEdges) {
@@ -1172,7 +1172,11 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1172
1172
  const clipChain = inheritedClipChain ?? (inheritedClip === undefined ? [] : [inheritedClip]);
1173
1173
  const localScale = scalarNumber(property(definition.attributes, 'scale'), 1) ?? 1;
1174
1174
  const scale = parentScale * localScale;
1175
- const position = objectProperty(property(definition.attributes, 'position'));
1175
+ const shownPosition = depth === 0 ? objectProperty(property(definition.attributes, 'show_position')) : undefined;
1176
+ const position = shownPosition ?? objectProperty(property(definition.attributes, 'position'));
1177
+ if (shownPosition !== undefined) {
1178
+ addFidelity(fidelity, 'modelled', 'window_show_position', 'The requested root window uses its declared settled show position.', definition);
1179
+ }
1176
1180
  const size = objectProperty(property(definition.attributes, 'size'));
1177
1181
  const scriptedX = rowValues?.[`${definition.name}.x`] ??
1178
1182
  scenario.values[`${definition.name}.x`] ??
@@ -1196,8 +1200,22 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1196
1200
  const orientationAxes = anchorAxes(orientation);
1197
1201
  const anchorX = anchoredCoordinate(parentRect.x, parentRect.width, localX, orientationAxes.horizontal);
1198
1202
  const anchorY = anchoredCoordinate(parentRect.y, parentRect.height, localY, orientationAxes.vertical);
1199
- const widthValue = size === undefined ? undefined : property(size, 'width', 'x');
1200
- const heightValue = size === undefined ? undefined : property(size, 'height', 'y');
1203
+ const scriptedWidth = rowValues?.[`${definition.name}.width`] ??
1204
+ scenario.values[`${definition.name}.width`] ??
1205
+ scenario.scriptedGui[`${definition.name}.width`];
1206
+ const scriptedHeight = rowValues?.[`${definition.name}.height`] ??
1207
+ scenario.values[`${definition.name}.height`] ??
1208
+ scenario.scriptedGui[`${definition.name}.height`];
1209
+ const widthValue = typeof scriptedWidth === 'number' && Number.isFinite(scriptedWidth)
1210
+ ? scriptedWidth
1211
+ : size === undefined
1212
+ ? undefined
1213
+ : property(size, 'width', 'x');
1214
+ const heightValue = typeof scriptedHeight === 'number' && Number.isFinite(scriptedHeight)
1215
+ ? scriptedHeight
1216
+ : size === undefined
1217
+ ? undefined
1218
+ : property(size, 'height', 'y');
1201
1219
  let width = scaledNumber(widthValue, parentRect.width, scale) ?? 0;
1202
1220
  let height = scaledNumber(heightValue, parentRect.height, scale) ?? 0;
1203
1221
  // A doubled percent names a far-edge coordinate in the parent, whereas a single
@@ -1455,10 +1473,17 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1455
1473
  width = declaredMaxWidth;
1456
1474
  if (width === 0 && inferWidth && /textbox/iu.test(definition.elementType))
1457
1475
  width = nativeTextBoxWidth * scale;
1458
- if (height === 0 && inferHeight && declaredMaxHeight !== undefined)
1476
+ const inferredTextHeight = height === 0 && inferHeight;
1477
+ if (inferredTextHeight && declaredMaxHeight !== undefined)
1459
1478
  height = declaredMaxHeight;
1460
1479
  const maxWidth = declaredMaxWidth ?? width;
1461
- const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work, resolvedInlineIcons);
1480
+ const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, scalarBoolean(property(definition.attributes, 'multiline')) === false ? 0 : maxWidth, context.work, resolvedInlineIcons);
1481
+ // Native maxHeight text boxes retain at least one font line. Explicit size
1482
+ // rectangles and inherited parent clipping still constrain their pixels.
1483
+ if (inferredTextHeight && declaredMaxHeight !== undefined && height < wrapped.lineHeight) {
1484
+ height = wrapped.lineHeight;
1485
+ addFidelity(fidelity, 'approximated', 'text_minimum_line_height', 'An inferred maxHeight smaller than one font line retains the first line; this native layout convention has no engine capture in this scenario.', definition);
1486
+ }
1462
1487
  const glyphLines = [];
1463
1488
  const inlineIcons = [];
1464
1489
  for (const [lineIndex, line] of wrapped.lines.entries()) {
@@ -1747,7 +1772,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1747
1772
  if (state === 'full-list' && rows === undefined)
1748
1773
  rows = Array.from({ length: 12 }, (_unused, index) => ({ index }));
1749
1774
  const dynamicList = context.dynamicListsByName.get(definition.name);
1750
- const listElement = /(?:grid|listbox|overlappingelements)/iu.test(definition.elementType);
1775
+ const listElement = /(?:grid|listbox|overlappingelements|positiontype)/iu.test(definition.elementType);
1751
1776
  const slotSize = objectProperty(property(definition.attributes, 'slotsize'));
1752
1777
  const slotHeight = scaledNumber(slotSize === undefined ? undefined : property(slotSize, 'height', 'y'), height, scale);
1753
1778
  const slotWidth = scaledNumber(slotSize === undefined ? undefined : property(slotSize, 'width', 'x'), width, scale);
@@ -1761,8 +1786,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1761
1786
  const maxHorizontal = scalarNumber(property(definition.attributes, 'max_slots_horizontal'), 1);
1762
1787
  const maxVertical = scalarNumber(property(definition.attributes, 'max_slots_vertical'), 1);
1763
1788
  const grid = /grid/iu.test(definition.elementType);
1789
+ const singleColumnFlow = grid &&
1790
+ maxHorizontal === 1 &&
1791
+ slotHeight !== undefined &&
1792
+ slotHeight <= scale &&
1793
+ (maxVertical === undefined || maxVertical <= 0);
1764
1794
  const rowPosition = (index, fallbackY) => {
1765
- if (!grid || slotWidth === undefined || slotHeight === undefined)
1795
+ if (!grid || singleColumnFlow || slotWidth === undefined || slotHeight === undefined)
1766
1796
  return { x: 0, y: fallbackY };
1767
1797
  if (maxVertical !== undefined && maxVertical > 0) {
1768
1798
  const rowsPerColumn = Math.max(1, Math.trunc(maxVertical));
@@ -1822,11 +1852,22 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1822
1852
  (row.countryScope === true
1823
1853
  ? (dynamicList?.countryScopeEntryContainer ?? dynamicList?.entryContainer)
1824
1854
  : (dynamicList?.entryContainer ?? dynamicList?.countryScopeEntryContainer));
1825
- const template = templateName === undefined ? undefined : context.elementsByName.get(templateName);
1855
+ const candidates = templateName === undefined
1856
+ ? []
1857
+ : (context.elementsByNameCandidates.get(templateName) ?? []);
1858
+ const localCandidates = candidates.filter(({ sourcePath }) => sourcePath === definition.sourcePath);
1859
+ const localRoots = localCandidates.filter(({ parentId }) => parentId === undefined);
1860
+ const template = localRoots.length === 1
1861
+ ? localRoots[0]
1862
+ : localCandidates.length === 1
1863
+ ? localCandidates[0]
1864
+ : candidates.length === 1
1865
+ ? candidates[0]
1866
+ : undefined;
1826
1867
  if (template === undefined) {
1827
1868
  diagnostics.push(diagnostic('GUI_DYNAMIC_LIST_TEMPLATE_MISSING', 'error', templateName === undefined
1828
1869
  ? `Dynamic list ${definition.name} has no entry container for scenario row ${index}.`
1829
- : `Dynamic list ${definition.name} cannot resolve entry container ${templateName}.`, definition));
1870
+ : `Dynamic list ${definition.name} cannot unambiguously resolve entry container ${templateName}.`, definition));
1830
1871
  continue;
1831
1872
  }
1832
1873
  const templatePosition = objectProperty(property(template.attributes, 'position'));
@@ -1842,7 +1883,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
1842
1883
  };
1843
1884
  await layoutElement(template, shiftedParent, childClip, scale, depth + 1, context, rowSuffix(index), index, row, instanceId, origo, resolveVisibleAppearance, childClipChain);
1844
1885
  const rendered = context.instancesById.get(`${template.id}${rowSuffix(index)}`);
1845
- rowY += (slotHeight ?? rendered?.unclippedRect.height ?? 0) + spacingY;
1886
+ rowY += Math.max(slotHeight ?? 0, rendered?.unclippedRect.height ?? 0) + spacingY;
1846
1887
  renderedRows += 1;
1847
1888
  }
1848
1889
  addFidelity(fidelity, renderedRows === rows.length ? 'modelled' : 'missing', dynamicList === undefined ? 'native_dynamic_list' : 'scripted_gui_dynamic_list', `Expanded ${renderedRows}/${rows.length} scenario rows for ${definition.name} through ${dynamicList === undefined ? 'explicit native' : 'scripted-GUI'} entry containers.`, definition);
@@ -2055,10 +2096,15 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2055
2096
  };
2056
2097
  const output = [];
2057
2098
  const elementsByName = new Map();
2099
+ const elementsByNameCandidates = new Map();
2058
2100
  for (const element of [...graph.elements].sort((left, right) => compareCodeUnits(right.sourcePath, left.sourcePath) ||
2059
- right.definitionOrder - left.definitionOrder))
2101
+ right.definitionOrder - left.definitionOrder)) {
2060
2102
  if (!elementsByName.has(element.name))
2061
2103
  elementsByName.set(element.name, element);
2104
+ const candidates = elementsByNameCandidates.get(element.name) ?? [];
2105
+ candidates.push(element);
2106
+ elementsByNameCandidates.set(element.name, candidates);
2107
+ }
2062
2108
  const context = {
2063
2109
  graph,
2064
2110
  scenario: layoutScenario,
@@ -2067,6 +2113,7 @@ export async function buildGuiScene(graph, scannedFiles, windowName, scenario, c
2067
2113
  diagnostics,
2068
2114
  elementsById,
2069
2115
  elementsByName,
2116
+ elementsByNameCandidates,
2070
2117
  dynamicListsByName,
2071
2118
  countryFlagProperties,
2072
2119
  constantElementEnabled,
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { ConditionScopeBindingSchema, ConditionResultSchema } from '../core/condition-schema.js';
2
+ import { ConditionScopeBindingSchema, ConditionResultSchema, ConditionControlMapSchema, GuiScenarioDateSchema, } from '../core/condition-schema.js';
3
3
  import { renderDimensionViolation, RENDER_MAX_DIMENSION } from '../core/render-budget.js';
4
4
  import { ServiceError } from '../core/result.js';
5
5
  import { GUI_SCENARIO_MAX_KEYS, GUI_SCENARIO_MAX_LIST_KEYS, GUI_SCENARIO_MAX_ROWS, GUI_SCENARIO_MAX_STRING_CHARACTERS, } from './limits.js';
@@ -104,6 +104,8 @@ const GuiPreviewScenarioBodySchema = z
104
104
  .max(128)
105
105
  .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
106
106
  description: z.string().max(500).optional(),
107
+ date: GuiScenarioDateSchema.optional(),
108
+ controls: ConditionControlMapSchema.optional(),
107
109
  resolution: GuiPreviewResolutionSchema.default({ width: 1920, height: 1080 }),
108
110
  uiScale: z
109
111
  .number()