hoi4-agent-tools 3.4.0 → 3.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 (109) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +26 -18
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +48 -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/index.js +1 -0
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +19 -1
  10. package/dist/hoi4_agent_tools/core/operation-result.js +4 -1
  11. package/dist/hoi4_agent_tools/core/scanner.js +1 -1
  12. package/dist/hoi4_agent_tools/core/scenario-model.js +6 -0
  13. package/dist/hoi4_agent_tools/core/scenario-state.js +3 -0
  14. package/dist/hoi4_agent_tools/core/workspace.js +13 -1
  15. package/dist/hoi4_agent_tools/event/graph.js +8 -1
  16. package/dist/hoi4_agent_tools/event/operations.js +16 -10
  17. package/dist/hoi4_agent_tools/event/service.js +1 -0
  18. package/dist/hoi4_agent_tools/event/source-analysis.js +1 -1
  19. package/dist/hoi4_agent_tools/focus/render.js +10 -2
  20. package/dist/hoi4_agent_tools/gui/layout.js +58 -11
  21. package/dist/hoi4_agent_tools/gui/scenario.js +3 -1
  22. package/dist/hoi4_agent_tools/gui/source-graph.js +4 -1
  23. package/dist/hoi4_agent_tools/gui/studio.js +32 -10
  24. package/dist/hoi4_agent_tools/gui/validators.js +3 -0
  25. package/dist/hoi4_agent_tools/map/model.js +121 -12
  26. package/dist/hoi4_agent_tools/map/operations.js +80 -7
  27. package/dist/hoi4_agent_tools/map/service.js +1 -0
  28. package/dist/hoi4_agent_tools/map/validation.js +38 -1
  29. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +18 -1
  30. package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
  31. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  32. package/dist/hoi4_agent_tools/mcp/tools/reference.js +60 -0
  33. package/dist/hoi4_agent_tools/mechanic/interpreter.js +91 -30
  34. package/dist/hoi4_agent_tools/mechanic/source.js +19 -4
  35. package/dist/hoi4_agent_tools/probability/service.js +2 -2
  36. package/dist/hoi4_agent_tools/probability/source-analysis.js +16 -0
  37. package/dist/hoi4_agent_tools/probability/value-evaluator.js +2 -1
  38. package/dist/hoi4_agent_tools/reference/lines.js +52 -0
  39. package/dist/hoi4_agent_tools/reference/operations.js +41 -0
  40. package/dist/hoi4_agent_tools/reference/service.js +427 -0
  41. package/dist/hoi4_agent_tools/reference/source-lookup.js +90 -0
  42. package/dist/hoi4_agent_tools/schemas/analysis.js +2 -0
  43. package/dist/hoi4_agent_tools/schemas/gui-requests.js +1 -1
  44. package/dist/hoi4_agent_tools/schemas/probability-requests.js +1 -1
  45. package/dist/hoi4_agent_tools/schemas/probability.js +26 -2
  46. package/dist/hoi4_agent_tools/schemas/reference.js +160 -0
  47. package/dist/hoi4_agent_tools/technology/operations.js +29 -1
  48. package/dist/hoi4_agent_tools/technology/render.js +11 -5
  49. package/dist/hoi4_agent_tools/technology/service.js +1 -0
  50. package/dist/hoi4_agent_tools/version.js +1 -1
  51. package/docs/README.md +3 -0
  52. package/docs/events.md +1 -1
  53. package/docs/gui.md +7 -0
  54. package/docs/jobs.md +1 -0
  55. package/docs/map.md +8 -0
  56. package/docs/mechanics.md +2 -1
  57. package/docs/probability.md +7 -0
  58. package/docs/reference.md +32 -0
  59. package/docs/research/reference-benchmark.md +28 -0
  60. package/docs/setup.md +6 -0
  61. package/docs/technology.md +5 -1
  62. package/examples/clients/codex-config.toml +2 -0
  63. package/package.json +4 -2
  64. package/schemas/configuration.schema.json +72 -58
  65. package/schemas/continuous-focus-palette.schema.json +1 -1
  66. package/schemas/custom-weighted-pool.schema.json +1 -1
  67. package/schemas/decision-inspect-input.schema.json +43 -26
  68. package/schemas/event-feature-manifest.schema.json +1 -1
  69. package/schemas/focus-plan.schema.json +1 -1
  70. package/schemas/focus-planning-sidecar.schema.json +1 -1
  71. package/schemas/gui-animation-source.schema.json +1 -1
  72. package/schemas/gui-helper.schema.json +1 -1
  73. package/schemas/gui-scenario.schema.json +102 -75
  74. package/schemas/helper-expansion-request.schema.json +1 -1
  75. package/schemas/helper-expansion-summary.schema.json +1 -1
  76. package/schemas/impact-inspect-input.schema.json +1 -1
  77. package/schemas/map-operation.schema.json +1 -1
  78. package/schemas/mechanic-test-input.schema.json +49 -32
  79. package/schemas/operation-result.schema.json +1 -1
  80. package/schemas/package-check-input.schema.json +1 -1
  81. package/schemas/package-manifest.schema.json +1 -1
  82. package/schemas/probability-analysis-result.schema.json +1 -1
  83. package/schemas/probability-compare-input.schema.json +160 -131
  84. package/schemas/probability-evaluate-input.schema.json +161 -132
  85. package/schemas/probability-inspect-input.schema.json +20 -11
  86. package/schemas/probability-render-input.schema.json +1 -1
  87. package/schemas/probability-scenario-set.schema.json +110 -90
  88. package/schemas/probability-sequence-input.schema.json +154 -134
  89. package/schemas/probability-simulate-input.schema.json +161 -132
  90. package/schemas/probability-sweep-input.schema.json +163 -134
  91. package/schemas/reference-context-input.schema.json +42 -0
  92. package/schemas/reference-read-input.schema.json +41 -0
  93. package/schemas/reference-search-input.schema.json +45 -0
  94. package/schemas/scenario-suite.schema.json +92 -75
  95. package/schemas/scenario-test-input.schema.json +103 -86
  96. package/schemas/shared-scenario.schema.json +39 -22
  97. package/schemas/source-lookup-input.schema.json +61 -0
  98. package/server.json +2 -2
  99. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  100. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  101. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  102. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  103. package/docs/images/readme/event-chain-mcp.png +0 -0
  104. package/docs/images/readme/focus-tree-mcp.png +0 -0
  105. package/docs/images/readme/map-mcp.png +0 -0
  106. package/docs/images/readme/probability-mcp.png +0 -0
  107. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  108. package/docs/images/readme/settings-events-mcp.png +0 -0
  109. package/docs/images/readme/technology-tree-mcp.png +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.5.0 - 2026-09-26
6
+
7
+ - 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.
8
+ - Compare hash-bound frozen probability source files at their logical gameplay paths while preserving physical source provenance and leaving current files untouched.
9
+ - Keep candidate eligibility overrides separate from weight-modifier conditions so an eligible scenario does not force every modifier to apply.
10
+ - Evaluate hidden and custom-tooltip trigger wrappers with their enclosed conditions while excluding presentation metadata from eligibility logic.
11
+ - 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.
12
+ - Honor single-line native labels and preserve the first inferred font line, with explicit fidelity reporting for undersized native text-height conventions.
13
+ - Preserve dotted event namespaces so a namespace query selects its own events rather than unrelated systems sharing the first name segment.
14
+ - Compact selected event diagrams without retaining empty row gaps from the rest of the workspace graph.
15
+ - 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.
16
+ - 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.
17
+ - Show alternatives within a national focus prerequisite group with dotted connectors, leaving individually required prerequisites solid.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+
22
+ ## 3.4.1 - 2026-09-25
23
+
24
+ - 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.
25
+ - Accept focused event inspection and render evidence when every event source was scanned and no blocking diagnostics remain, while retaining deferred workspace-wide helper and lifecycle projections as an explicit boundary.
26
+ - Keep repeated window-local GUI control names additive in the shared index, exclude engine-owned compound-control roles from scripted click-effect requirements, and preserve compact operation summaries when linked evidence exceeds the MCP wire budget.
27
+ - Parse each active scripted-effect source document once when building mechanic helper inventories so bounded real-workspace scenarios do not multiply parser state by the number of helpers in a file.
28
+ - Interpret declared temporary-variable assignment, arithmetic, rounding, clamping, and scope-valued inputs with chain-wide unscoped semantics in bounded mechanic scenarios.
29
+ - Evaluate `has_variable` against declared scenario state so guarded defaults and subsequent temporary-variable arithmetic remain deterministic.
30
+ - Follow a live replacement worker through its terminal publication instead of returning a nonterminal job record during concurrent checkpoint recovery.
31
+
5
32
  ## 3.4.0 - 2026-09-20
6
33
 
7
34
  - Add branch-local focus cleanup with pinned anchors and symmetry groups, connector measurements, source-linked crops, and scenario-backed structural route diagnostics.
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 {
@@ -278,6 +287,13 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
278
287
  unresolved: [],
279
288
  };
280
289
  }
290
+ if (key === 'has_variable') {
291
+ const state = scopeContext.expression === 'ROOT' ? scenario.state : scopeContext.binding.state;
292
+ return {
293
+ state: Object.hasOwn(state, right) || Object.hasOwn(state, `variable.${right}`) ? 'true' : 'false',
294
+ unresolved: [],
295
+ };
296
+ }
281
297
  if (key === 'tag' || key === 'original_tag') {
282
298
  const actor = scopeContext.expression === 'ROOT'
283
299
  ? scenario.actor
@@ -311,6 +327,36 @@ function evaluateAssignmentCore(assignment, scenario, candidate, definitions, he
311
327
  unresolved: [],
312
328
  };
313
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
+ }
314
360
  const helper = definitions.scriptedTriggers.get(key);
315
361
  if (helper !== undefined && bool(right) !== undefined) {
316
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;
@@ -1369,6 +1369,7 @@ export class SymbolIndex {
1369
1369
  this.#active.set(key, active);
1370
1370
  const additiveCategory = active.kind === 'decision_category' ||
1371
1371
  active.kind === 'technology_category' ||
1372
+ active.kind === 'gui_element' ||
1372
1373
  active.kind === 'texture';
1373
1374
  for (const candidate of group) {
1374
1375
  candidate.overridden = additiveCategory ? candidate.sourceShadowed : candidate !== active;
@@ -73,7 +73,12 @@ export class JobWorkerHost {
73
73
  for (;;) {
74
74
  try {
75
75
  await this.scheduler.run(this.owner, 1024, signal, () => this.capacity.run(signal, (lease) => this.launch(workspaceId, id, lease, principal)));
76
- return await this.jobs.get(workspaceId, id, principal);
76
+ const current = await this.jobs.get(workspaceId, id, principal);
77
+ if (['completed', 'cancelled', 'failed'].includes(current.status))
78
+ return current;
79
+ if (current.owner !== undefined && jobOwnerLiveness(current.owner) === 'alive')
80
+ return await this.followOwner(workspaceId, id, principal);
81
+ throw new ServiceError('JOB_WORKER_EXIT', 'The worker stopped without publishing a terminal job outcome');
77
82
  }
78
83
  catch (error) {
79
84
  const current = await this.jobs.get(workspaceId, id, principal);
@@ -108,6 +113,19 @@ export class JobWorkerHost {
108
113
  }
109
114
  }
110
115
  }
116
+ async followOwner(workspaceId, id, principal) {
117
+ for (;;) {
118
+ const record = await this.jobs.get(workspaceId, id, principal);
119
+ if (['completed', 'failed', 'cancelled'].includes(record.status))
120
+ return record;
121
+ const liveness = record.owner === undefined ? 'dead' : jobOwnerLiveness(record.owner);
122
+ if (liveness !== 'alive')
123
+ throw new ServiceError(liveness === 'unknown' ? 'JOB_OWNER_UNRESOLVED' : 'JOB_WORKER_EXIT', liveness === 'unknown'
124
+ ? 'The replacement execution owner cannot be verified on this host'
125
+ : 'The replacement worker stopped without publishing a terminal job outcome');
126
+ await delay(100);
127
+ }
128
+ }
111
129
  async launch(workspaceId, id, lease, principal) {
112
130
  // Resolve the same configured workspace and grants again immediately before dispatch.
113
131
  await this.jobs.get(workspaceId, id, principal);
@@ -114,6 +114,9 @@ function boundedResult(result, actualBytes) {
114
114
  : 'Tool output exceeded the wire budget; nonessential inline details were omitted',
115
115
  details: { actualBytes, maxBytes: MAX_TOOL_RESULT_BYTES },
116
116
  };
117
+ const compactData = result.data !== null && typeof result.data === 'object' && !Array.isArray(result.data)
118
+ ? result.data
119
+ : {};
117
120
  return {
118
121
  status: result.status,
119
122
  code: result.code.slice(0, 256),
@@ -141,7 +144,7 @@ function boundedResult(result, actualBytes) {
141
144
  },
142
145
  },
143
146
  ],
144
- data: {},
147
+ data: jsonBytesWithin(compactData, 2_048) ? compactData : {},
145
148
  };
146
149
  }
147
150
  function jsonStringBytes(value) {
@@ -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 },
@@ -37,21 +37,27 @@ export function eventGraphCounts(graph, artifacts) {
37
37
  }
38
38
  export function eventGraphValidation(graph) {
39
39
  const counts = eventGraphCounts(graph, 0);
40
+ const analysisBoundaryPassed = graph.complete || (graph.analysisMode === 'focused' && graph.skippedSourceCount === 0);
41
+ const passed = analysisBoundaryPassed && counts.blockingDiagnostics === 0;
40
42
  return {
41
- passed: graph.complete && counts.blockingDiagnostics === 0,
43
+ passed,
42
44
  checks: [
43
45
  {
44
46
  id: 'event-analysis',
45
- passed: graph.complete && counts.blockingDiagnostics === 0,
46
- message: graph.analysisMode === 'focused'
47
- ? 'Large workspace analysis deferred workspace-wide helper projections and lifecycle passes; direct evidence is linked'
47
+ passed,
48
+ message: counts.blockingDiagnostics > 0
49
+ ? `${counts.blockingDiagnostics} blocking event-chain diagnostics; full evidence is linked`
48
50
  : graph.complete
49
- ? `${counts.blockingDiagnostics} blocking event-chain diagnostics; full evidence is linked`
50
- : graph.issues.some(({ code }) => code === 'EVENT_HELPER_DEPTH_LIMIT' ||
51
- code === 'EVENT_HELPER_PROJECTION_LIMIT' ||
52
- code === 'EVENT_HELPER_STATE_PROJECTION_LIMIT')
53
- ? 'Helper expansion reached a depth or materialization boundary; helper_expansion mode provides bounded source-linked continuation'
54
- : `${graph.skippedSourceCount} event-analysis source(s) were skipped; full evidence is linked`,
51
+ ? 'The event-chain analysis is complete and has no blocking diagnostics'
52
+ : graph.analysisMode === 'focused' && graph.skippedSourceCount === 0
53
+ ? 'Whole-workspace helper projections and lifecycle passes were deferred; the requested event analysis retains direct source evidence'
54
+ : graph.analysisMode === 'focused'
55
+ ? `${graph.skippedSourceCount} event-analysis source(s) were skipped; linked evidence records the partial boundary`
56
+ : graph.issues.some(({ code }) => code === 'EVENT_HELPER_DEPTH_LIMIT' ||
57
+ code === 'EVENT_HELPER_PROJECTION_LIMIT' ||
58
+ code === 'EVENT_HELPER_STATE_PROJECTION_LIMIT')
59
+ ? 'Helper expansion reached a depth or materialization boundary; helper_expansion mode provides bounded source-linked continuation'
60
+ : `${graph.skippedSourceCount} event-analysis source(s) were skipped; full evidence is linked`,
55
61
  },
56
62
  ],
57
63
  };
@@ -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) {