hoi4-agent-tools 4.2.0 → 4.4.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 (124) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
  3. package/dist/hoi4_agent_tools/core/configuration.js +11 -0
  4. package/dist/hoi4_agent_tools/core/domain-validation.js +6 -1
  5. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  6. package/dist/hoi4_agent_tools/core/game-documentation.js +24 -0
  7. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  8. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  9. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  10. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  11. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  12. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  13. package/dist/hoi4_agent_tools/decision/service.js +1 -22
  14. package/dist/hoi4_agent_tools/event/graph.js +8 -1
  15. package/dist/hoi4_agent_tools/event/service.js +8 -0
  16. package/dist/hoi4_agent_tools/event/source-analysis.js +10 -6
  17. package/dist/hoi4_agent_tools/focus/importer.js +8 -6
  18. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  19. package/dist/hoi4_agent_tools/focus/tool-support.js +10 -1
  20. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  21. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  22. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  23. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  24. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  25. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  26. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  27. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  28. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  29. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  30. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  31. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  32. package/dist/hoi4_agent_tools/map/model.js +15 -2
  33. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  34. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  35. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  36. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  37. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  38. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  39. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  40. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  41. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  42. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  43. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  44. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  45. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  46. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  47. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  48. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  49. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  50. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  51. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  52. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  53. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  54. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  55. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  56. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  57. package/dist/hoi4_agent_tools/map/render.js +36 -0
  58. package/dist/hoi4_agent_tools/map/service.js +191 -16
  59. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  60. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  61. package/dist/hoi4_agent_tools/map/water.js +227 -0
  62. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  63. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  64. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  65. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  66. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  67. package/dist/hoi4_agent_tools/map/world.js +462 -124
  68. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  69. package/dist/hoi4_agent_tools/probability/service.js +18 -18
  70. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  71. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  72. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  73. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  74. package/dist/hoi4_agent_tools/schemas/probability.js +7 -1
  75. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  76. package/dist/hoi4_agent_tools/version.js +1 -1
  77. package/docs/analysis.md +1 -1
  78. package/docs/configuration.md +17 -14
  79. package/docs/map.md +258 -15
  80. package/docs/probability.md +1 -1
  81. package/docs/tools.md +4 -4
  82. package/docs/troubleshooting.md +11 -11
  83. package/npm-shrinkwrap.json +109 -91
  84. package/package.json +3 -3
  85. package/schemas/configuration.schema.json +65 -58
  86. package/schemas/continuous-focus-palette.schema.json +1 -1
  87. package/schemas/custom-weighted-pool.schema.json +1 -1
  88. package/schemas/decision-inspect-input.schema.json +1 -1
  89. package/schemas/error-log-input.schema.json +1 -1
  90. package/schemas/event-feature-manifest.schema.json +1 -1
  91. package/schemas/focus-plan.schema.json +1 -1
  92. package/schemas/focus-planning-sidecar.schema.json +1 -1
  93. package/schemas/geography-model.schema.json +1148 -0
  94. package/schemas/gui-animation-source.schema.json +1 -1
  95. package/schemas/gui-helper.schema.json +5 -11
  96. package/schemas/gui-scenario.schema.json +1 -1
  97. package/schemas/helper-expansion-request.schema.json +1 -1
  98. package/schemas/helper-expansion-summary.schema.json +1 -1
  99. package/schemas/impact-inspect-input.schema.json +1 -1
  100. package/schemas/map-operation.schema.json +2156 -259
  101. package/schemas/mechanic-test-input.schema.json +1 -1
  102. package/schemas/mod-index-input.schema.json +1 -1
  103. package/schemas/operation-result.schema.json +1 -1
  104. package/schemas/package-check-input.schema.json +1 -1
  105. package/schemas/package-manifest.schema.json +1 -1
  106. package/schemas/probability-analysis-result.schema.json +31 -7
  107. package/schemas/probability-compare-input.schema.json +1 -1
  108. package/schemas/probability-evaluate-input.schema.json +1 -1
  109. package/schemas/probability-inspect-input.schema.json +1 -1
  110. package/schemas/probability-render-input.schema.json +1 -1
  111. package/schemas/probability-scenario-set.schema.json +1 -1
  112. package/schemas/probability-sequence-input.schema.json +1 -1
  113. package/schemas/probability-simulate-input.schema.json +1 -1
  114. package/schemas/probability-sweep-input.schema.json +1 -1
  115. package/schemas/reference-context-input.schema.json +1 -1
  116. package/schemas/reference-read-input.schema.json +1 -1
  117. package/schemas/reference-search-input.schema.json +1 -1
  118. package/schemas/reference-sheets.schema.json +267 -0
  119. package/schemas/scenario-suite.schema.json +1 -1
  120. package/schemas/scenario-test-input.schema.json +1 -1
  121. package/schemas/script-validate-input.schema.json +1 -1
  122. package/schemas/shared-scenario.schema.json +1 -1
  123. package/schemas/source-lookup-input.schema.json +1 -1
  124. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,6 +1,35 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 4.4.0 - 2026-10-24
4
+
5
+ - Collect test fixtures that a killed or crashed test run left in the temporary folder: the test setup now also sweeps `hoi4-*` fixtures that carry an owner record of a process that has ended (`sweepTemporaryDirectories` option `ownedFixturesOnly`). Unmarked folders and folders of a live process are never removed. Interrupted local runs had left 2,441 such folders (640 MB).
6
+ - Fix the province count of generated worlds (found by the map import campaign, ZR-4). Extra provinces in high and wet land were added on top of `landProvincePixels`, so reference worlds, whose terrain is wet, got 50 to 95% more land provinces than requested (18,000 to 23,000 instead of about 13,000); they now redistribute the requested size, and lakes are cut at the sea size. When neither province size is given and the land would still need more than 14,000 provinces, both sizes grow towards 13,000 (`MAP_WORLD_PROVINCE_SIZE_RAISED`); chosen sizes above 21,000 provinces are refused before generation (`MAP_PARTITION_PROVINCE_COUNT_HIGH`) instead of failing on a 23 MB `unitstacks.txt`. This changes the province partition, and with it every derived file, of every `create_world` for a seed.
7
+ - Repair a coast that crosses the east-west seam by one pixel (`MAP_WORLD_SEAM_CORNER_REPAIRED`) instead of refusing the picture with `MAP_WORLD_UNREPRESENTABLE_TOPOLOGY`. `MAP_TEXT_FILE_LIMIT` and `MAP_TEXT_RECORD_LIMIT` now name the file.
8
+ - Register a drawing made of sheets onto the world frame: `create_world.reference.sheets` or `reference.sheetsPath` (schema `reference-sheets.schema.json`, version 1) map each sheet with anchor, target and scale (negative to mirror) or an affine, with an optional clip and an order for overlaps (ADR 0067, step 2). The picture is read at the resolution the sheets need and every layer derives from the registered picture. New errors `MAP_REFERENCE_SHEETS_*` and `MAP_REFERENCE_SHEET_*` name the sheet, pixel count and position at fault.
9
+ - Report import evidence for registered, frame-fitted or exact-palette references and for a picture beside a geography model: `map-import-report.json`, `map-import-classes.png`, `map-import-alignment.png`, `map-import-registration.png`, the inline `data.import` of a model preview, and the land agreement between model and picture (`reference.agreement`, defaults 0.85 to block with `MAP_IMPORT_SOURCES_DISAGREE` and 0.95 to warn), with model sites, volcanoes and river ends on the wrong surface (`MAP_IMPORT_FEATURE_SURFACE`). A model preview with a picture now also loads and registers the picture. Public schema change: new optional fields and one new published schema (minor version).
10
+ - Add `create_world.reference.paletteMatch` (`nearest`, the default, or `exact`) and `reference.fit` (`contain`, the default, or `frame`) for pictures that are already the game layout, such as a cleaned class map (ADR 0067, step 1). `exact` classifies by exact palette colour, skips the visual heuristics and fails with `MAP_REFERENCE_PALETTE_UNKNOWN_COLOR` (colours, counts, source pixels) or `MAP_REFERENCE_PALETTE_CONFLICT`; `frame` maps the picture onto `width` x `height` at the frame resolution instead of the 3,072-pixel working copy and refuses another shape with `MAP_REFERENCE_FRAME_ASPECT`. Public schema change: new optional fields (minor version). Requests without them are unchanged.
11
+ - Carry a whole-mod apply in one transaction (ZR-5): the file ceiling rises from 5,000 to 15,000 and the authenticated manifest ceiling from 16 to 32 MiB. Large applies replace files in batches that grow with the file count (16 files up to 1,024, at most 256), so the manifest is written about 128 times instead of twice per 16 files; ordinary rewrites are unchanged. `TRANSACTION_STRUCTURE_LIMIT` now names each exceeded limit and the remedy. See the amendment to ADR 0047.
12
+ - Resolve `workspaceId: "current"` for sessions started outside every mod, such as a coding agent working in another repository or its subagents. When the client roots and the working directory identify no mod, the server now uses the new optional configuration key `defaultWorkspace`, then the only mod workspace, then the one mod that has the named source (`relativePath` of the focus tools, `path` of `hoi4.script_validate`). `WORKSPACE_CONTEXT_NOT_FOUND` and `WORKSPACE_CONTEXT_REQUIRED` now list up to 32 workspace IDs and names and say how to fix the call. A default outside a principal's grants is never used. See ADR 0066.
13
+ - Security update: zod 4.6.7 (GHSA-mrhx-c7xc-m3w5, memory exhaustion when validating oversized constrained collections), body-parser 2.4.0, undici 8.12.3 and vite 8.2.4, so `npm audit --audit-level=high` passes again. The regenerated schemas now state the fixed tuple lengths the server already enforced (`items: false`, `minItems`, `maxItems`), and the scalar `anyOf` in `gui-helper.schema.json` became an equivalent type list. No accepted input changes. With zod 4.6, a JSON object key `__proto__` in a strictly validated input (such as a geography model) is refused as an unknown key; zod 4.4 dropped it silently. Neither version let it reach `Object.prototype`. The `qs` override moves from 6.16.0 to 6.16.1, which body-parser 2.4.0 requires.
14
+ - Fix lakes in generated worlds (ADR 0053). Lake pixels are written at the sea height 89 with a one to two pixel bank instead of height 93, where the engine draws water only 56% opaque, and use the ocean's graphical terrain instead of index 14, whose `lakes` entry names no atlas tile (`texture = 255`). `buildWorldContext` never selects such an entry and reports it in `unrenderedTerrain`. Enclosed water below 60 pixels becomes land (`MAP_LAKE_TOO_SMALL`) and lake water that touches the open sea becomes sea (`MAP_LAKE_CONNECTED_TO_SEA`). The water colour maps now follow the base game (shelf-to-deep gradient, lake tint). This changes the heightmap, terrain bitmap and water colour maps of every `create_world` request that has lakes, so their file hashes change; provinces, states, countries and the other files are unchanged for a seed.
15
+ - Add optional `create_world.look` (`vanilla-temperate`, `fertile`, `arid`, `custom`) and `colormap` (`profile`: `calibrated` or `legacy`, `palette`) for a dark, varied land colour map calibrated to the base game, permanent-snow tint, and farmland, sparse-forest and grassy-mountain terrain indices (ADR 0054). Without them the colour map is unchanged.
16
+ - Add optional `create_world.edge` (`mode`: `sea-band`, `rim`, `none`; `bandPx`, `polarPx`, `rimPx`) and `ambientFrame` for the wrapping world seam and the north and south frame borders (ADR 0061), with the diagnostics `MAP_EDGE_LAND_CLEARED`, `MAP_EDGE_FEATURE_IN_BAND`, `MAP_EDGE_BAND_TOO_WIDE` and `MAP_EDGE_RIM_ADJACENCIES`. Without them nothing changes.
17
+ - Validate the appearance files (`MAP_RIVER_*`, `MAP_TERRAIN_BITMAP_*`, `MAP_HEIGHT_*`, `MAP_TREES_*`, `MAP_COLORMAP_*`, `MAP_WATER_COLORMAP_FLAT`, `MAP_NORMAL_*`, `MAP_CITIES_*`, `MAP_LAKE_*`, `MAP_EDGE_LAND_CUT`, `MAP_SEAM_LAND_ADJACENCY`, `MAP_POLAR_LAND`) in `hoi4.map_inspect` and in every world being created. Thresholds are set so the installed base game raises no error. Existing mods can see new warnings; only a defect that covers a whole lake, river or file is an error.
18
+ - Public schema change: new optional fields on `create_world` (minor version at release; the schemas are regenerated).
19
+ - Add the geography model, version 1, as an optional input of `create_world`: `geography` (inline) or `geographyPath` (a workspace-relative `.json` file of at most 4 MiB, loaded with the canonical-path guard, a verified single read and a stale-source dependency like `reference.imagePath`). A strict, versioned schema describes seas, lands, regions, ranges, passes, volcanoes, plateaus, rivers, lakes, biomes, straits, sites, roads, places and climate in normalized world coordinates; `schemas/geography-model.schema.json` is published. The public `hoi4.map_rewrite` schema change is additive (a minor version).
20
+ - Resolve the model deterministically (features sorted by id, rings oriented, defaults filled, areas, containment and cross-references derived, one canonical hash) and validate it with `MAP_GEOGRAPHY_*` diagnostics. `hoi4.map_rewrite` with `preview: true` returns `map-geography-overlay.png`, `map-geography-report.json` and a `data.geography` summary without planning any map. Without `preview`, a model is loaded and validated and the write is blocked with `MAP_GEOGRAPHY_SYNTHESIS_UNAVAILABLE` until relief, hydrology and biome synthesis exist. See ADR 0055.
21
+ - Measure border naturalness. `hoi4.map_inspect` accepts `quality: true` (full inspection) and returns `data.quality` for the province, state, strategic-region and country borders: alignment with rivers, steep gradients, lake shores and terrain-class changes, enrichment over chance, the plain natural share of border length, locally straight samples, long straight runs and the worst artificial runs, with the artifact `map-border-quality.<revision>.json` and the diagnostics `MAP_BORDER_ARTIFICIAL_REVIEW`, `MAP_BORDER_NOT_MEANINGFUL`, `MAP_BORDER_INPUT_MISSING` and `MAP_BORDER_QUALITY_UNAVAILABLE` (none fail validation). The new `border-quality` overlay draws natural and artificial state borders and the worst runs. Both are additive and optional, so existing requests are unchanged (compatibility review: new optional input field, new overlay value and new optional output field; next release is a minor version). See ADR 0059.
22
+ - Add the opt-in feature-aware partition for `create_world` (ADR 0058): `partition: "feature-aware"` (default `legacy`, which keeps every old request byte-identical; a world given a geography defaults to feature-aware) cuts provinces along rivers, ridges, steep slopes, wall ranges and shores with a watershed, sizes them by terrain, fertility, coast, rivers and the geography region's density (sea provinces small on the shelf, large in the open sea, two sea provinces across a narrow strait), and clusters states and strategic regions along natural lines, geography regions and named seas. The optional `partitionTuning` (`landArea`, `seaArea`, `provincesPerState`, `regionSize`, `barrierStrength`, `minProvincePixels`, `narrowStraitPx`) sets the bands. New diagnostics `MAP_PARTITION_*`: contradictory bands, a map too small for the minimum province, `partitionTuning` with `legacy`, a plan above 21,000 provinces (also names the fields to change, closing the late `MAP_TEXT_FILE_LIMIT` failure for this partition), a warning above 14,000 provinces for both partitions, and info notes for provinces cut along rivers and straits and for coast pixels adjusted at diagonal contacts. The reusable service (`partitionWorld`, `buildProvinceGraph`, `provinceEdgeCosts`, `partitionFieldsFromBitmaps`, `partitionFieldsFromGeography`) takes a typed `PartitionFields` input that the relief and hydrology stages will also fill. For a geography world the partition reads the model's regions, named seas, region densities (`provinceAreaPx`, `provincesPerState`) and wall ranges, the synthesised heights and the walls, fort lines and crests of the synthesis barrier field, so states and strategic regions stay inside one geography region and no province straddles a wall; the mask smoothing runs before the synthesis there, so a coast pixel turned to land gets a land height. The terrain synthesis and the feature-aware partition stop when the request is cancelled. Public schema change: new optional fields on `create_world` (minor version at release; the schemas are regenerated). Compatibility review: the partition adds optional inputs only and no output schema field; the output schema change of this release is the additive `synthesis` and `unsupported` fields of the model preview (below). Responses can change without a schema change: a `create_world` above 14,000 provinces now carries the `MAP_PARTITION_PROVINCE_COUNT_HIGH` warning with the legacy partition too, while its files stay byte-identical.
23
+
24
+ - Synthesize relief and rivers from a geography model (ADR 0056). `create_world` with `geography` or `geographyPath` now builds the land, `heightmap.bmp`, `world_normal.bmp`, `rivers.bmp` and the permanent-snow terrain from the model instead of being blocked: ranges as ridge, massif, escarpment and wall profiles with asymmetry, spurs, passes and snow lines, volcanoes with craters, plateaus and chasms, fractal base relief and coast ramps, on a half-size macro grid with full-resolution detail; erosion-aware rivers (priority-flood conditioning, named rivers routed as least-cost paths with bends, tributaries, deltas and lake outlets, an accumulation network at a target density, stream-power valleys, widths by catchment from 3 to 11) written as one-pixel orthogonal paths with a green source per river, red tributary ends and yellow distributary starts; the normal map from the float height field at the base game's response. A request without a geography is unchanged, byte for byte.
25
+ - Add optional `create_world.relief` (`scale`: `vanilla`, `low`, `dramatic`; `detail` 0 to 1) and let `create_world.rivers` be `{ density }` (share of land pixels, default 0.02, at most 2.5% of land or 130,000 pixels) as well as an integer; with a geography an integer limits the unnamed river systems. Both need a geography (`MAP_RELIEF_REQUIRES_GEOGRAPHY`, `MAP_RIVER_DENSITY_REQUIRES_GEOGRAPHY`). A model that declares biomes, climate, fertility, region terrain shares or sea ice is still blocked with `MAP_GEOGRAPHY_SYNTHESIS_UNAVAILABLE`, whose message and details now name those parts; the model preview's `data.geography[].synthesis` is `available` or `unavailable` and `unsupported` lists the parts.
26
+ - New generation-time diagnostics for geography worlds: `MAP_RELIEF_SYNTHESIZED` (statistics and the first named rivers), `MAP_PASS_NOT_LOWER` and `MAP_RIVER_UNREACHABLE_MOUTH` (errors), `MAP_RANGE_CREST_LOW`, `MAP_VOLCANO_NO_CRATER`, `MAP_RELIEF_FLAT`, `MAP_RIVER_STRAIGHT_RUN`, `MAP_RIVER_DENSITY`, `MAP_RIVER_PIXEL_ROUTE_INCOMPLETE` (warnings), `MAP_LAKE_ENDORHEIC`, `MAP_RIVER_STREAMS_DROPPED` and `MAP_RIVER_DELTA_SKIPPED` (info). The result also carries typed barrier, valley, crest-distance and mountain fields for the feature-aware partition (documented in the ADR).
27
+ - Public schema change: new optional field `relief` and a union type for `rivers` on `create_world`, and the new output fields `synthesis` (now `available` or `unavailable`) and `unsupported` in the model preview summary (minor version at release; the schemas are regenerated).
28
+
29
+ ## 4.3.0 - 2026-10-19
30
+
31
+ - Run probability analysis on every installed HOI4 version. `metadata.gameVersionVerification` records the observed build as `workspace_verified`, `workspace_observed` or `version_unknown` instead of refusing with `PROBABILITY_GAME_VERSION_UNSUPPORTED` or `PROBABILITY_GAME_VERSION_UNVERIFIED`; decision inspection evaluates AI weights on any version.
32
+ - Recognize every effect the installed game documents in `documentation/effects_documentation.md` as native in event and focus analysis, so effects added by later patches are not reported as missing scripted helpers.
4
33
 
5
34
  ## 4.2.0 - 2026-10-19
6
35
 
@@ -10,6 +10,10 @@ import { TemporaryDirectoryLifetime, sweepTemporaryDirectories } from './tempora
10
10
  export const REFERENCE_SOURCE_MAX_BYTES = 1_073_741_824;
11
11
  export const REFERENCE_WORKING_MAX_SIDE = 3_072;
12
12
  export const REFERENCE_WORKING_MAX_BYTES = 33_554_432;
13
+ /** Ceilings for a working copy sized to an output frame instead of the default side limit. */
14
+ export const REFERENCE_FRAME_WORKING_MAX_PIXELS = 32_000_000;
15
+ // A noisy drawing barely compresses: allow four bytes per pixel (RGBA) and the PNG overhead.
16
+ export const REFERENCE_FRAME_WORKING_MAX_BYTES = REFERENCE_FRAME_WORKING_MAX_PIXELS * 4 + 1_048_576;
13
17
  function supportedRasterHeader(bytes) {
14
18
  return ((bytes.length >= 8 &&
15
19
  [137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) ||
@@ -109,7 +113,7 @@ async function removePrivateSnapshot(snapshot) {
109
113
  }
110
114
  }
111
115
  /** Decode a private snapshot copied and hashed from the same verified FD, using fixed memory. */
112
- export async function readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal) {
116
+ export async function readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal, working) {
113
117
  if (!/\.(?:png|jpe?g|webp)$/iu.test(relativePath) || relativePath.includes(':'))
114
118
  throw new ServiceError('REFERENCE_IMAGE_PATH_INVALID', 'Reference must name a local raster image');
115
119
  const resolved = await resolver.resolvePath(workspaceId, relativePath, 'read', ['mod'], principal);
@@ -162,18 +166,36 @@ export async function readReferenceImageSource(resolver, workspaceId, relativePa
162
166
  !['png', 'jpeg', 'webp'].includes(metadata.format))
163
167
  throw new ServiceError('REFERENCE_IMAGE_UNSUPPORTED', 'Reference must be a single-frame supported raster image');
164
168
  signal?.throwIfAborted();
165
- const decoded = await input
166
- .resize({
167
- width: REFERENCE_WORKING_MAX_SIDE,
168
- height: REFERENCE_WORKING_MAX_SIDE,
169
- fit: 'inside',
170
- withoutEnlargement: true,
171
- })
169
+ const requested = working?.size(metadata.width, metadata.height);
170
+ if (requested !== undefined &&
171
+ (!Number.isSafeInteger(requested.width) ||
172
+ !Number.isSafeInteger(requested.height) ||
173
+ requested.width < 1 ||
174
+ requested.height < 1 ||
175
+ Math.min(requested.width, metadata.width) * Math.min(requested.height, metadata.height) >
176
+ REFERENCE_FRAME_WORKING_MAX_PIXELS))
177
+ throw new ServiceError('REFERENCE_WORKING_PIXEL_LIMIT', 'Reference working copy for this frame exceeds its pixel limit', { limit: REFERENCE_FRAME_WORKING_MAX_PIXELS });
178
+ const decoded = await (requested === undefined
179
+ ? input.resize({
180
+ width: REFERENCE_WORKING_MAX_SIDE,
181
+ height: REFERENCE_WORKING_MAX_SIDE,
182
+ fit: 'inside',
183
+ withoutEnlargement: true,
184
+ })
185
+ : requested.width >= metadata.width && requested.height >= metadata.height
186
+ ? input
187
+ : input.resize({
188
+ width: Math.min(requested.width, metadata.width),
189
+ height: Math.min(requested.height, metadata.height),
190
+ fit: 'fill',
191
+ kernel: working?.kernel === 'nearest' ? sharp.kernel.nearest : sharp.kernel.lanczos3,
192
+ }))
172
193
  .png()
173
194
  .toBuffer({ resolveWithObject: true });
174
195
  await verifySource(resolved.root, source);
175
196
  signal?.throwIfAborted();
176
- if (decoded.data.length > REFERENCE_WORKING_MAX_BYTES)
197
+ if (decoded.data.length >
198
+ (working === undefined ? REFERENCE_WORKING_MAX_BYTES : REFERENCE_FRAME_WORKING_MAX_BYTES))
177
199
  throw new ServiceError('REFERENCE_WORKING_BYTE_LIMIT', 'Reference working image exceeds its byte limit');
178
200
  return {
179
201
  source: {
@@ -192,6 +192,17 @@ export const serverConfigurationSchema = z
192
192
  workspaceStorageRoot: z.string().min(1).optional(),
193
193
  storageRoots: z.array(z.string().min(1)).max(WORKSPACE_MAX_SOURCE_ROOTS).default([]),
194
194
  workspaces: z.array(workspaceRegistrationSchema).max(WORKSPACE_MAX_REGISTRATIONS).default([]),
195
+ /**
196
+ * The mod workspace (ID or short form) used when neither the client roots nor the working
197
+ * directory identify one. Over HTTP it applies only to principals granted that workspace.
198
+ */
199
+ defaultWorkspace: z
200
+ .string()
201
+ .regex(/^[a-z][a-z0-9_-]{0,63}$/u)
202
+ .refine((value) => value !== 'current', {
203
+ message: 'The default workspace must name a workspace, not "current"',
204
+ })
205
+ .optional(),
195
206
  http: z
196
207
  .object({
197
208
  host: z.string().default('127.0.0.1'),
@@ -54,7 +54,12 @@ export async function postValidateTransaction(engine, transaction, principal, si
54
54
  relativePath.toLowerCase().endsWith('.txt') &&
55
55
  changed.has(sourcePathComparisonKey(relativePath)))) {
56
56
  const document = parseClausewitz(file.bytes, file.displayPath);
57
- const imported = importFocusTrees(document, { references: catalog });
57
+ const imported = importFocusTrees(document, {
58
+ references: catalog,
59
+ ...(snapshot.documentedEffects === undefined
60
+ ? {}
61
+ : { nativeEffects: snapshot.documentedEffects.names }),
62
+ });
58
63
  diagnostics.push(...imported.diagnostics);
59
64
  for (const plan of imported.plans) {
60
65
  const layout = await layoutFocusTreeAsync(plan, signal === undefined ? {} : { signal });
@@ -1,4 +1,5 @@
1
1
  import { compareCodeUnits, hashCanonical } from './canonical.js';
2
+ import { installedEffectCommands } from './game-documentation.js';
2
3
  import { SymbolIndex } from './index.js';
3
4
  import { IndexSegmentCache } from './index-segments.js';
4
5
  import { PersistentAnalysisCache, } from './persistent-analysis-cache.js';
@@ -231,6 +232,7 @@ export class CoreEngine {
231
232
  .at(-1)?.[1].index;
232
233
  const index = await SymbolIndex.buildAsync(files, controller.signal, this.indexSegments, previous);
233
234
  await persistentAnalysisCache?.persist(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
235
+ const documentedEffects = await installedEffectCommands(workspace.gameRoot);
234
236
  const snapshot = {
235
237
  workspaceId,
236
238
  revision,
@@ -240,6 +242,7 @@ export class CoreEngine {
240
242
  skippedSourceCount: index.skippedSourceCount,
241
243
  skippedSources: index.skippedSources,
242
244
  diagnostics: index.diagnostics,
245
+ ...(documentedEffects === undefined ? {} : { documentedEffects }),
243
246
  };
244
247
  if ((this.#scanGenerations.get(workspaceId) ?? 0) === generation) {
245
248
  // Replace this request's earlier revision and drop earlier generations; keep the
@@ -0,0 +1,24 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { sha256Bytes } from './canonical.js';
4
+ /** `## name` headings of an installed `*_documentation.md` file. */
5
+ export function documentedCommandNames(markdown) {
6
+ return new Set([...markdown.matchAll(/^## ([a-z_][a-z0-9_]*)[ \t]*\r?$/gmu)].map((match) => match[1]));
7
+ }
8
+ /**
9
+ * Read the effect names the installed game documents. Each game version ships this file, so
10
+ * effects added by a later patch are recognized without a tool update. Absent or unreadable
11
+ * documentation returns `undefined` and callers fall back to their built-in list.
12
+ */
13
+ export async function installedEffectCommands(gameRoot) {
14
+ if (gameRoot === undefined)
15
+ return undefined;
16
+ try {
17
+ const bytes = await readFile(path.join(gameRoot, 'documentation', 'effects_documentation.md'));
18
+ const names = documentedCommandNames(bytes.toString('utf8'));
19
+ return names.size === 0 ? undefined : { names, sha256: sha256Bytes(bytes) };
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
@@ -7,7 +7,23 @@ export async function resolveOperationWorkspaceId(engine, context, workspaceId,
7
7
  return engine.resolver.resolveWorkspaceId(workspaceId, context.principal);
8
8
  }
9
9
  export async function resolveOperationWorkspaceForSource(engine, context, workspaceId, relativePath, signal) {
10
- const resolvedWorkspaceId = await resolveOperationWorkspaceId(engine, context, workspaceId, signal);
10
+ let resolvedWorkspaceId;
11
+ try {
12
+ resolvedWorkspaceId = await resolveOperationWorkspaceId(engine, context, workspaceId, signal);
13
+ }
14
+ catch (error) {
15
+ // A session outside every mod still names its mod through a source only one mod has.
16
+ if (workspaceId !== 'current' ||
17
+ relativePath === undefined ||
18
+ !(error instanceof ServiceError) ||
19
+ !['WORKSPACE_CONTEXT_NOT_FOUND', 'WORKSPACE_CONTEXT_REQUIRED'].includes(error.code)) {
20
+ throw error;
21
+ }
22
+ const unique = await engine.resolver.resolveUniqueModSourceWorkspaceId(relativePath, context.principal, signal);
23
+ if (unique === undefined)
24
+ throw error;
25
+ return unique;
26
+ }
11
27
  if (workspaceId !== 'current' || relativePath === undefined)
12
28
  return resolvedWorkspaceId;
13
29
  try {
@@ -11,6 +11,7 @@ export const TEMPORARY_STALE_MS = 3 * 60 * 60 * 1000;
11
11
  // Dated event evaluation directories contain retained benchmark evidence, not scratch files.
12
12
  const scratchName = /^hoi4-(?:reference-source|event-eval)-[A-Za-z0-9]{6}$/u;
13
13
  const referenceSnapshotName = /^\.reference-snapshot-[A-Za-z0-9]{6}$/u;
14
+ const ownedFixtureName = /^hoi4-[a-z0-9-]{1,64}-[A-Za-z0-9]{6}$/u;
14
15
  const ownerSchema = z
15
16
  .object({
16
17
  version: z.literal(1),
@@ -101,7 +102,11 @@ export async function sweepTemporaryDirectories(parent, options = {}) {
101
102
  }
102
103
  }))).flat();
103
104
  const candidates = [];
104
- const names = options.referenceSnapshotsOnly ? referenceSnapshotName : scratchName;
105
+ const names = options.referenceSnapshotsOnly
106
+ ? referenceSnapshotName
107
+ : options.ownedFixturesOnly
108
+ ? ownedFixtureName
109
+ : scratchName;
105
110
  for (const name of (await readdir(root)).filter((name) => names.test(name)).sort()) {
106
111
  const directory = path.join(root, name);
107
112
  if (protectedRoots.some((protectedRoot) => overlaps(directory, protectedRoot)))
@@ -114,7 +119,7 @@ export async function sweepTemporaryDirectories(parent, options = {}) {
114
119
  (await realpath(directory)) !== directory)
115
120
  continue;
116
121
  const record = await owner(directory);
117
- if (options.referenceSnapshotsOnly && record == null)
122
+ if ((options.referenceSnapshotsOnly || options.ownedFixturesOnly) && record == null)
118
123
  continue;
119
124
  // An interrupted sweep must not leave a permanent lock. Malformed or live locks
120
125
  // stay protected; only a confirmed dead local owner can relinquish its lock.
@@ -1,13 +1,27 @@
1
- // A generated world can contain thousands of state/region files. Review collections
2
- // remain bounded independently of the unchanged 16 MiB authenticated manifest cap.
3
- export const TRANSACTION_MAX_FILES = 5_000;
1
+ // A generated world holds thousands of state and region files, and a whole-mod content apply
2
+ // (ADR 0047 amendment) more than 8,000; a manifest entry is about 1 KB, so 15,000 files fit
3
+ // the 32 MiB authenticated manifest cap with room for longer paths.
4
+ export const TRANSACTION_MAX_FILES = 15_000;
4
5
  export const TRANSACTION_MAX_OPERATIONS = 1_000;
5
6
  export const TRANSACTION_MAX_VALIDATION_CHECKS = 100;
6
7
  export const TRANSACTION_MAX_DIAGNOSTICS = 100;
7
8
  export const TRANSACTION_MAX_ARTIFACTS = 6_000;
8
9
  export const TRANSACTION_MAX_APPLIED_FILES = TRANSACTION_MAX_FILES;
9
10
  export const TRANSACTION_MAX_READ_DEPENDENCIES = 10_000;
10
- export const TRANSACTION_MAX_MANIFEST_BYTES = 16_777_216;
11
+ export const TRANSACTION_MAX_MANIFEST_BYTES = 33_554_432;
12
+ /** Smallest replacement batch: one intent and one checkpoint manifest write per batch. */
11
13
  export const TRANSACTION_MAX_PENDING_FILES = 16;
14
+ /** Large transactions use bigger batches, so they write the manifest about this many times. */
15
+ export const TRANSACTION_TARGET_BATCHES = 64;
16
+ /** Largest replacement batch, bounding the identity checks one intent records. */
17
+ export const TRANSACTION_MAX_BATCH_FILES = 256;
18
+ /**
19
+ * Files per replacement batch. Every batch rewrites the whole manifest twice, and the manifest
20
+ * grows with the file count, so fixed batches made large applies quadratic; up to 1,024 files
21
+ * the batch stays at 16 as before.
22
+ */
23
+ export function transactionBatchSize(files) {
24
+ return Math.min(TRANSACTION_MAX_BATCH_FILES, Math.max(TRANSACTION_MAX_PENDING_FILES, Math.ceil(files / TRANSACTION_TARGET_BATCHES)));
25
+ }
12
26
  // A maximum-size world needs before/after blobs plus staging and rollback copies.
13
27
  export const TRANSACTION_MAX_JOURNAL_BYTES = 1_073_741_824;
@@ -10,7 +10,7 @@ import { hashSourceBytes, prepareSource, verifySource } from './scanner.js';
10
10
  import { ServiceError } from './result.js';
11
11
  import { decodeSource } from './source/encoding.js';
12
12
  import { containedGeneratedPath, isWithin, resolvedWorkspaceIdentity } from './workspace.js';
13
- import { TRANSACTION_MAX_ARTIFACTS, TRANSACTION_MAX_DIAGNOSTICS, TRANSACTION_MAX_FILES, TRANSACTION_MAX_JOURNAL_BYTES, TRANSACTION_MAX_MANIFEST_BYTES, TRANSACTION_MAX_OPERATIONS, TRANSACTION_MAX_PENDING_FILES, TRANSACTION_MAX_READ_DEPENDENCIES, TRANSACTION_MAX_VALIDATION_CHECKS, } from './transaction-limits.js';
13
+ import { TRANSACTION_MAX_ARTIFACTS, TRANSACTION_MAX_DIAGNOSTICS, TRANSACTION_MAX_FILES, TRANSACTION_MAX_JOURNAL_BYTES, TRANSACTION_MAX_MANIFEST_BYTES, TRANSACTION_MAX_OPERATIONS, transactionBatchSize, TRANSACTION_MAX_READ_DEPENDENCIES, TRANSACTION_MAX_VALIDATION_CHECKS, } from './transaction-limits.js';
14
14
  import { PACKAGE_VERSION, TRANSACTION_VERSION } from '../version.js';
15
15
  import { transactionManifestSchema } from '../schemas/transaction.js';
16
16
  import { TransactionCanonicalSnapshot } from './transaction-canonical.js';
@@ -220,7 +220,24 @@ export class TransactionManager {
220
220
  (input.readDependencies?.length ?? 0) > TRANSACTION_MAX_READ_DEPENDENCIES ||
221
221
  (input.artifacts?.length ?? 0) > TRANSACTION_MAX_ARTIFACTS ||
222
222
  (input.diagnostics?.length ?? 0) > TRANSACTION_MAX_DIAGNOSTICS) {
223
- throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', 'Transaction input exceeds the supported review structure limits', {
223
+ const exceeded = [
224
+ input.changes.length > TRANSACTION_MAX_FILES
225
+ ? `${input.changes.length} files (limit ${TRANSACTION_MAX_FILES})`
226
+ : '',
227
+ input.operations.length > TRANSACTION_MAX_OPERATIONS
228
+ ? `${input.operations.length} operations (limit ${TRANSACTION_MAX_OPERATIONS})`
229
+ : '',
230
+ (input.readDependencies?.length ?? 0) > TRANSACTION_MAX_READ_DEPENDENCIES
231
+ ? `${input.readDependencies?.length} read dependencies (limit ${TRANSACTION_MAX_READ_DEPENDENCIES})`
232
+ : '',
233
+ (input.artifacts?.length ?? 0) > TRANSACTION_MAX_ARTIFACTS
234
+ ? `${input.artifacts?.length} artifacts (limit ${TRANSACTION_MAX_ARTIFACTS})`
235
+ : '',
236
+ (input.diagnostics?.length ?? 0) > TRANSACTION_MAX_DIAGNOSTICS
237
+ ? `${input.diagnostics?.length} diagnostics (limit ${TRANSACTION_MAX_DIAGNOSTICS})`
238
+ : '',
239
+ ].filter((entry) => entry.length > 0);
240
+ throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', `Transaction input exceeds the supported review structure limits: ${exceeded.join(', ')}. Combine operations into fewer entries, or split the change into transactions under the limits`, {
224
241
  files: input.changes.length,
225
242
  filesLimit: TRANSACTION_MAX_FILES,
226
243
  operations: input.operations.length,
@@ -518,7 +535,8 @@ export class TransactionManager {
518
535
  'integrityHash',
519
536
  'authenticationTag',
520
537
  ]);
521
- const batchSize = manifest.version >= 2 ? TRANSACTION_MAX_PENDING_FILES : 1;
538
+ const batchSize = manifest.version >= 2 ? transactionBatchSize(manifest.files.length) : 1;
539
+ let pendingByPath;
522
540
  for (let offset = 0; offset < manifest.files.length; offset += batchSize) {
523
541
  const batch = manifest.files.slice(offset, offset + batchSize);
524
542
  if (manifest.version >= 2) {
@@ -543,6 +561,7 @@ export class TransactionManager {
543
561
  return entry.value;
544
562
  });
545
563
  await this.writeManifest(workspace, manifest, options.hooks, canonicalSnapshot);
564
+ pendingByPath = new Map(manifest.pendingFiles.map((pending) => [pending.relativePath, pending]));
546
565
  await options.hooks?.afterBatchIntent?.(batch.map(({ relativePath }) => relativePath), offset / batchSize);
547
566
  }
548
567
  for (const [batchIndex, file] of batch.entries()) {
@@ -559,7 +578,7 @@ export class TransactionManager {
559
578
  if (actualBeforeReplace !== file.beforeSha256) {
560
579
  throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
561
580
  }
562
- const pending = manifest.pendingFiles?.find(({ relativePath }) => relativePath === file.relativePath);
581
+ const pending = pendingByPath?.get(file.relativePath);
563
582
  if (pending !== undefined &&
564
583
  (!sameFileIdentity(await fileIdentity(target.target), pending.beforeIdentity) ||
565
584
  !sameFileIdentity(target.staged === null ? null : await fileIdentity(target.staged), pending.stagedIdentity))) {
@@ -0,0 +1,54 @@
1
+ import { sha256Bytes } from './canonical.js';
2
+ import { ServiceError } from './result.js';
3
+ import { prepareSource, readBoundedFile, verifySource } from './scanner.js';
4
+ /**
5
+ * Read one JSON document from the authorized mod workspace: workspace-relative `.json` only,
6
+ * resolved against the principal's grants, opened once, size-bounded, hashed from the very bytes
7
+ * that are parsed and re-verified against the opened identity afterwards. The evidence is a
8
+ * transaction read dependency, so a changed file blocks a write as stale. The caller validates
9
+ * the parsed value with its own schema.
10
+ */
11
+ export async function readWorkspaceJsonSource(resolver, workspaceId, requestedPath, maxBytes, codes, principal, signal) {
12
+ // Backslashes separate directories on every platform, so a request resolves the same everywhere.
13
+ const relativePath = requestedPath.replaceAll('\\', '/');
14
+ if (!/\.json$/iu.test(relativePath) || relativePath.includes(':'))
15
+ throw new ServiceError(codes.pathInvalid, `${codes.label} must name a workspace-relative .json file`);
16
+ const resolved = await resolver.resolvePath(workspaceId, relativePath, 'read', ['mod'], principal);
17
+ const source = await prepareSource(resolved.root, relativePath, signal);
18
+ try {
19
+ if (!source.metadata.isFile())
20
+ throw new ServiceError('SOURCE_NOT_REGULAR_FILE', `${codes.label} must be a regular file`);
21
+ if (source.metadata.size < 1 || source.metadata.size > maxBytes)
22
+ throw new ServiceError(codes.tooLarge, `${codes.label} must hold between 1 byte and ${maxBytes} bytes`, { limit: maxBytes });
23
+ const bytes = await readBoundedFile(source.handle, maxBytes, signal);
24
+ await verifySource(resolved.root, source);
25
+ const sha256 = sha256Bytes(bytes);
26
+ let text = bytes.toString('utf8');
27
+ if (text.charCodeAt(0) === 0xfeff)
28
+ text = text.slice(1);
29
+ let value;
30
+ try {
31
+ value = JSON.parse(text);
32
+ }
33
+ catch {
34
+ throw new ServiceError(codes.jsonInvalid, `${codes.label} is not valid JSON`);
35
+ }
36
+ return {
37
+ value,
38
+ bytes: bytes.length,
39
+ evidence: {
40
+ absolutePath: source.canonicalPath,
41
+ displayPath: `mod:${relativePath}`,
42
+ relativePath,
43
+ rootKind: resolved.root.kind,
44
+ loadOrder: resolved.root.loadOrder,
45
+ size: source.metadata.size,
46
+ modifiedMs: source.metadata.mtimeMs,
47
+ sha256,
48
+ },
49
+ };
50
+ }
51
+ finally {
52
+ await source.handle.close();
53
+ }
54
+ }
@@ -207,6 +207,17 @@ function workspaceAliases(workspace) {
207
207
  `mod_${nameSlug}`,
208
208
  ]);
209
209
  }
210
+ const WORKSPACE_CONTEXT_FIX = 'Pass workspaceId with one of the listed IDs or the mod folder name, start the client in the mod folder, or set defaultWorkspace in the server configuration';
211
+ const WORKSPACE_CANDIDATE_LIMIT = 32;
212
+ /** The mods a caller could name, bounded and without host paths. */
213
+ function workspaceCandidates(workspaces) {
214
+ const listed = workspaces.slice(0, WORKSPACE_CANDIDATE_LIMIT);
215
+ return {
216
+ workspaceIds: listed.map(({ id }) => id),
217
+ workspaceNames: listed.map(({ name }) => name),
218
+ workspaceCount: workspaces.length,
219
+ };
220
+ }
210
221
  function requestedWorkspaceAlias(value) {
211
222
  const staleDiscovered = /^mod_(.+)_[a-f0-9]{12}$/u.exec(value);
212
223
  return staleDiscovered?.[1] ?? value;
@@ -297,6 +308,7 @@ export class WorkspaceResolver {
297
308
  #workspaceStorageRoot;
298
309
  #serverStateRoot;
299
310
  #serverState;
311
+ #defaultWorkspaceId;
300
312
  constructor(configuration) {
301
313
  this.configuration = configuration;
302
314
  }
@@ -351,6 +363,13 @@ export class WorkspaceResolver {
351
363
  resolver.#byId.set(workspace.id, workspace);
352
364
  }
353
365
  await resolver.discoverModWorkspaces();
366
+ if (configuration.defaultWorkspace !== undefined) {
367
+ const id = resolver.resolveWorkspaceId(configuration.defaultWorkspace);
368
+ if (resolver.#byId.get(id)?.registration.kind !== 'mod') {
369
+ throw new ServiceError('WORKSPACE_DEFAULT_UNKNOWN', `The configured default workspace is not a registered mod workspace: ${configuration.defaultWorkspace}`, { defaultWorkspace: configuration.defaultWorkspace });
370
+ }
371
+ resolver.#defaultWorkspaceId = id;
372
+ }
354
373
  if (configuration.serverStateRoot !== undefined) {
355
374
  resolver.#serverState = await ServerState.create(configuration.serverStateRoot);
356
375
  resolver.#serverStateRoot = resolver.#serverState.root;
@@ -373,22 +392,39 @@ export class WorkspaceResolver {
373
392
  if (containing.length > 1) {
374
393
  throw new ServiceError('WORKSPACE_CONTEXT_AMBIGUOUS', 'The MCP working directory is inside more than one writable mod workspace', { currentDirectory });
375
394
  }
376
- if (workspaces.length === 1)
377
- return workspaces[0].id;
378
- throw new ServiceError('WORKSPACE_CONTEXT_REQUIRED', 'The MCP working directory does not identify one writable mod workspace', { currentDirectory, workspaceIds: workspaces.map(({ id }) => id) });
395
+ const fallback = this.fallbackWorkspaceId(workspaces);
396
+ if (fallback !== undefined)
397
+ return fallback;
398
+ throw new ServiceError('WORKSPACE_CONTEXT_REQUIRED', `The MCP working directory does not identify one writable mod workspace. ${WORKSPACE_CONTEXT_FIX}`, { currentDirectory, ...workspaceCandidates(workspaces) });
379
399
  }
380
400
  /** Resolve the active mod from filesystem roots advertised by the connected MCP client. */
381
401
  async resolveClientWorkspaceId(rootPaths, principal) {
382
402
  const canonicalRoots = await Promise.all(rootPaths.map((rootPath) => canonicalPath(rootPath)));
383
- const matches = this.list(principal).filter((workspace) => workspace.registration.kind === 'mod' &&
384
- canonicalRoots.some((rootPath) => isWithin(workspace.modRoot, rootPath) || isWithin(rootPath, workspace.modRoot)));
403
+ const workspaces = this.list(principal).filter(({ registration }) => registration.kind === 'mod');
404
+ const matches = workspaces.filter((workspace) => canonicalRoots.some((rootPath) => isWithin(workspace.modRoot, rootPath) || isWithin(rootPath, workspace.modRoot)));
385
405
  if (matches.length === 1)
386
406
  return matches[0].id;
387
407
  if (matches.length === 0) {
388
- throw new ServiceError('WORKSPACE_CONTEXT_NOT_FOUND', 'The active MCP client roots do not identify a registered HOI4 mod workspace');
408
+ const fallback = this.fallbackWorkspaceId(workspaces);
409
+ if (fallback !== undefined)
410
+ return fallback;
411
+ throw new ServiceError('WORKSPACE_CONTEXT_NOT_FOUND', workspaces.length === 0
412
+ ? 'No writable mod workspace is configured for the MCP server'
413
+ : `The active MCP client roots do not identify a registered HOI4 mod workspace. ${WORKSPACE_CONTEXT_FIX}`, workspaceCandidates(workspaces));
389
414
  }
390
415
  throw new ServiceError('WORKSPACE_CONTEXT_AMBIGUOUS', 'The active MCP client roots identify more than one HOI4 mod workspace', { workspaceIds: matches.map(({ id }) => id) });
391
416
  }
417
+ /**
418
+ * The mod used when neither the client roots nor the working directory name one: the
419
+ * configured default when the caller may use it, else the only mod the caller can see.
420
+ */
421
+ fallbackWorkspaceId(workspaces) {
422
+ if (this.#defaultWorkspaceId !== undefined &&
423
+ workspaces.some(({ id }) => id === this.#defaultWorkspaceId)) {
424
+ return this.#defaultWorkspaceId;
425
+ }
426
+ return workspaces.length === 1 ? workspaces[0].id : undefined;
427
+ }
392
428
  /** Find one mod containing an existing source path when a stale client root selected another mod. */
393
429
  async resolveUniqueModSourceWorkspaceId(relativePath, principal, signal) {
394
430
  const matches = [];
@@ -99,20 +99,7 @@ export class DecisionAnalyzer {
99
99
  }));
100
100
  const ai = input.mode === 'inventory'
101
101
  ? undefined
102
- : await inspectDecisionAi(this.engine, before, input.id, scenarios, overlay, input.principal, input.signal).catch((error) => {
103
- // AI weights depend on adapters verified for one game version. A game patch leaves
104
- // the AI score unresolved; visibility, availability and cost evidence still stand.
105
- if (error instanceof ServiceError &&
106
- (error.code === 'PROBABILITY_GAME_VERSION_UNSUPPORTED' ||
107
- error.code === 'PROBABILITY_GAME_VERSION_UNVERIFIED'))
108
- return {
109
- status: 'unresolved',
110
- code: error.code,
111
- message: error.message,
112
- details: error.details,
113
- };
114
- throw error;
115
- });
102
+ : await inspectDecisionAi(this.engine, before, input.id, scenarios, overlay, input.principal, input.signal);
116
103
  const sourceComplete = input.mode === 'inventory'
117
104
  ? inventory.complete
118
105
  : input.mode === 'compare'
@@ -193,14 +180,6 @@ export class DecisionAnalyzer {
193
180
  : {}),
194
181
  });
195
182
  result.code = complete ? 'DECISION_ANALYZED' : 'DECISION_ANALYZED_PARTIAL';
196
- if (ai !== undefined && 'code' in ai)
197
- result.diagnostics.push({
198
- category: 'configuration',
199
- code: ai.code,
200
- severity: 'warning',
201
- message: `${ai.message}; AI weights are unresolved, the remaining evidence is unaffected`,
202
- details: ai.details,
203
- });
204
183
  result.artifacts = [publicArtifactLink(artifact)];
205
184
  result.proposedFiles = overlay?.changes.map(({ path }) => path).slice(0, 64) ?? [];
206
185
  setInlineFilesScanned(result, before.files.map(({ displayPath }) => displayPath));
@@ -1383,14 +1383,21 @@ function analyzeFragments(snapshot, files, catalog, options, budget) {
1383
1383
  activeDecisionPaths: reads.bind('active-decisions', 'decision', catalog.activeDecisionPaths),
1384
1384
  inventoryComplete: snapshot.complete,
1385
1385
  catalogFingerprint: catalog.fingerprint,
1386
+ ...(options.documentedEffects === undefined
1387
+ ? {}
1388
+ : { documentedEffectKeys: options.documentedEffects.names }),
1386
1389
  };
1390
+ // A game update that documents new effects changes how fragments read, so it keys the cache.
1391
+ const fragmentFingerprint = options.documentedEffects === undefined
1392
+ ? catalog.fingerprint
1393
+ : `${catalog.fingerprint}:${options.documentedEffects.sha256}`;
1387
1394
  for (const file of files) {
1388
1395
  budget.spend('source_fragment');
1389
1396
  if (!shouldAnalyzeEventFile(file))
1390
1397
  continue;
1391
1398
  if (snapshot.index.isSourceSkipped(file.displayPath))
1392
1399
  continue;
1393
- const cacheKey = eventSemanticFragmentCacheKey(file, catalog.fingerprint);
1400
+ const cacheKey = eventSemanticFragmentCacheKey(file, fragmentFingerprint);
1394
1401
  const fragment = semanticFragment(dependencies, options.cache, file, cacheKey, reads, () => analyzeEventSource(file, context));
1395
1402
  fragments.push(fragment);
1396
1403
  }
@@ -3,6 +3,7 @@ import { inspectHelperExpansion } from '../core/helper-expansion.js';
3
3
  import { eventHelperInventory } from './helper-expansion.js';
4
4
  import { clearSemanticDependencies } from '../core/semantic-dependencies.js';
5
5
  import { canonicalJson, canonicalJsonByteLength, compareCodeUnits, hashCanonical, sha256Bytes, } from '../core/canonical.js';
6
+ import { installedEffectCommands } from '../core/game-documentation.js';
6
7
  import { boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
7
8
  import { ServiceError } from '../core/result.js';
8
9
  import { RenderBudget } from '../core/render-budget.js';
@@ -714,6 +715,8 @@ function coherentBranchPartitions(graph, nodeLimit, maximumGroups, signal) {
714
715
  export class EventChainViewer {
715
716
  engine;
716
717
  #state;
718
+ /** Effect names the installed game documented at each workspace's last scan. */
719
+ #documentedEffects = new Map();
717
720
  constructor(engine) {
718
721
  this.engine = engine;
719
722
  this.#state = sharedState(engine);
@@ -757,8 +760,11 @@ export class EventChainViewer {
757
760
  return cached.graph;
758
761
  const buildAnalysisMode = requestedAnalysisMode;
759
762
  const projectHelpers = buildAnalysisMode === 'focused' ? false : requestedProjectHelpers;
763
+ const documentedEffects = await installedEffectCommands(workspace.gameRoot);
764
+ this.#documentedEffects.set(workspaceId, documentedEffects);
760
765
  const graph = await buildEventGraphAsync(snapshot, {
761
766
  ...(options.signal === undefined ? {} : { signal: options.signal }),
767
+ ...(documentedEffects === undefined ? {} : { documentedEffects }),
762
768
  cache: this.#state.fragments,
763
769
  workspaceIdentity: workspace.workspaceIdentity,
764
770
  projectHelpers,
@@ -1321,8 +1327,10 @@ export class EventChainViewer {
1321
1327
  skippedSources: index.skippedSources,
1322
1328
  diagnostics: index.diagnostics,
1323
1329
  };
1330
+ const documentedEffects = this.#documentedEffects.get(workspaceId);
1324
1331
  return buildEventGraph(snapshot, {
1325
1332
  ...(signal === undefined ? {} : { signal }),
1333
+ ...(documentedEffects === undefined ? {} : { documentedEffects }),
1326
1334
  cache: this.#state.fragments,
1327
1335
  workspaceIdentity: workspace.workspaceIdentity,
1328
1336
  projectHelpers: false,