hoi4-agent-tools 4.3.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.
- package/CHANGELOG.md +25 -1
- package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
- package/dist/hoi4_agent_tools/core/configuration.js +11 -0
- package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
- package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
- package/dist/hoi4_agent_tools/core/transactions.js +23 -4
- package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
- package/dist/hoi4_agent_tools/core/workspace.js +42 -6
- package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
- package/dist/hoi4_agent_tools/map/bmp.js +9 -1
- package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
- package/dist/hoi4_agent_tools/map/colormap.js +128 -0
- package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
- package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
- package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
- package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
- package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
- package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
- package/dist/hoi4_agent_tools/map/model.js +15 -2
- package/dist/hoi4_agent_tools/map/operations.js +111 -8
- package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
- package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
- package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
- package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
- package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
- package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
- package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
- package/dist/hoi4_agent_tools/map/partition.js +163 -0
- package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
- package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
- package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
- package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
- package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
- package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
- package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
- package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
- package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
- package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
- package/dist/hoi4_agent_tools/map/render.js +36 -0
- package/dist/hoi4_agent_tools/map/service.js +191 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
- package/dist/hoi4_agent_tools/map/validation.js +9 -0
- package/dist/hoi4_agent_tools/map/water.js +227 -0
- package/dist/hoi4_agent_tools/map/world-context.js +24 -1
- package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
- package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
- package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world.js +462 -124
- package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
- package/dist/hoi4_agent_tools/reference/operations.js +3 -2
- package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
- package/dist/hoi4_agent_tools/schemas/map.js +209 -2
- package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/configuration.md +17 -14
- package/docs/map.md +258 -15
- package/docs/tools.md +4 -4
- package/docs/troubleshooting.md +11 -11
- package/npm-shrinkwrap.json +109 -91
- package/package.json +3 -3
- package/schemas/configuration.schema.json +65 -58
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/geography-model.schema.json +1148 -0
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +5 -11
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +2156 -259
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +25 -7
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/reference-sheets.schema.json +267 -0
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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).
|
|
4
28
|
|
|
5
29
|
## 4.3.0 - 2026-10-19
|
|
6
30
|
|
|
@@ -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
|
|
166
|
-
|
|
167
|
-
width
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 >
|
|
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'),
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
384
|
-
|
|
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
|
-
|
|
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 = [];
|