hoi4-agent-tools 4.4.0 → 4.6.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 (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  3. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  4. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  5. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  6. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  7. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  8. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  9. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  10. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  16. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  17. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  18. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  19. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  20. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  21. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  22. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  23. package/dist/hoi4_agent_tools/map/model.js +2 -1
  24. package/dist/hoi4_agent_tools/map/operations.js +282 -38
  25. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  26. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  27. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
  28. package/dist/hoi4_agent_tools/map/service.js +93 -16
  29. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  30. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  31. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  32. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  33. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  34. package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
  35. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  36. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  37. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  38. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  39. package/dist/hoi4_agent_tools/map/world.js +400 -145
  40. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  41. package/dist/hoi4_agent_tools/schemas/map.js +277 -34
  42. package/dist/hoi4_agent_tools/version.js +1 -1
  43. package/docs/focus.md +6 -0
  44. package/docs/map.md +132 -4
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +1 -1
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/geography-model.schema.json +13 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +1541 -786
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/reference-sheets.schema.json +1 -1
  81. package/schemas/scenario-suite.schema.json +1 -1
  82. package/schemas/scenario-test-input.schema.json +1 -1
  83. package/schemas/script-validate-input.schema.json +1 -1
  84. package/schemas/shared-scenario.schema.json +1 -1
  85. package/schemas/source-lookup-input.schema.json +1 -1
  86. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.0 - 2026-10-25
4
+
5
+ - Add the territory assignment service (ADR 0060). `assign_territory` grows up to 512 countries over the states or provinces of a map from capital and anchor seeds (state, province or pixel) with forced and blocked land, preferred strategic regions, subjects inside an overlord, merges, island policies, size targets by weight, extra cores and claims, and writes owner, controller, cores and claims. Borders follow rivers, ranges, lake shores and terrain changes (a shortest-path growth with barrier-weighted steps, size balancing, a corridor for cut-off seeds and a local search that shortens the artificial border); the result reports sizes against targets and the same seeds grown on distance alone. With `unit: "province"` the states a border cuts are regrouped in one pass, conserving population, resources and buildings exactly. `repartition_states` regroups the states of chosen owners along natural lines, and `update_states` edits up to 5,000 states in one rewrite. New diagnostics `MAP_TERRITORY_*`.
6
+ - Land listed in `assign_territory.unowned`, and landmasses without a seed under `islandPolicy: "unowned"`, lose their owner (and controller) with `ownership.unowned: "clear"`, the default, or keep it with `"keep"`; states outside `scope` are never edited. Refusals name the state or province of the request (`MAP_TERRITORY_SEED_UNAVAILABLE`, `_FORCED_BLOCKED`, `_FORCED_CONFLICT`, `_FORCED_UNREACHABLE` carry `stateId` or `provinceId`). Results of a territory operation add `data.territory`.
7
+ - Add `borderQuality` (`off`, `report`, `enforce`) and `borderTargets` to the territory operations and to `create_world` (G-58). The border naturalness of the proposed map is measured after the whole request and reported (`MAP_BORDER_QUALITY_MEASURED`, artifact `map-territory-report.json`); `enforce` blocks the write below the targets (`MAP_TERRITORY_BORDER_ARTIFICIAL`, `MAP_WORLD_BORDER_ARTIFICIAL`) and is refused for the legacy partition.
8
+ - A feature-aware `create_world` without a reference picture now grows its generated countries along natural lines instead of blind breadth-first growth; legacy worlds are byte-identical, and workspace-owned countries (`countries.source: "workspace"`) keep the realm growth of ADR 0069.
9
+ - Public schema change: new operation kinds `assign_territory`, `repartition_states` and `update_states` and new optional fields `borderQuality` and `borderTargets` on `create_world` (minor version at release; the schemas are regenerated).
10
+ - Plan straits, passes and walls from a geography model (ADR 0062). A world built from a geography now writes each `strait` as a named `sea` row in `adjacencies.csv` with a rule in `adjacency_rules.txt` (`open`, `army-closed`, `navy-closed`), `impassable` rows for every pair of land provinces across a range with `barrier: "impassable"` except at its passes, land forts (`bunker`) at passes with a `fort` level, and sea provinces for a lake with `gameplay: "inland-sea"` with a canal row to the open sea. The partition now uses the model's regions, named seas and walls. New optional `create_world.autoStraits` (`true` or `{ maxGapPx }`). Diagnostics: `MAP_CROSSINGS_PLANNED`, `MAP_INLAND_SEA_CREATED`, `MAP_INLAND_SEA_SEALED`, `MAP_STRAIT_UNRESOLVED`, `MAP_STRAIT_DUPLICATE`, `MAP_STRAIT_NOT_NARROW`, `MAP_PASS_BLOCKED`, `MAP_RANGE_NOT_CLOSED` and the refusal `MAP_IMPASSABLE_ROW_LIMIT`. `hoi4.map_inspect` and every created world also check rules against rows and wall rows on any map: `MAP_ADJACENCY_RULE_MISSING`, `_DUPLICATE`, `_INVALID`, `_UNUSED`, `MAP_IMPASSABLE_NOT_ADJACENT`, `MAP_IMPASSABLE_NOT_LAND`, `MAP_ADJACENCY_ROW_LIMIT`, `MAP_RAILWAY_CROSSES_IMPASSABLE`, `MAP_UNIT_POSITION_MISSING`; thresholds are set so the installed game raises nothing above information. Worlds without a geography are unchanged.
11
+ - Plan ports, development, names and railways from a geography model (ADR 0063). Ports follow a harbour score (concavity, shelter, river mouth, depth class, sites) with levels from site importance; population, category, infrastructure, factories, resources (ranges, `mine` sites, jungle) and victory points follow fertility, rivers, coast, passes and sites with no random rolls; states, strategic regions, victory points and lake provinces are named from sites, lakes, rivers, ranges, regions, lands and seas by containment, with counted placeholders (`MAP_NAME_PLACEHOLDER`); railways use a terrain-aware cost (ground, climb, rivers, gates, roads) and trunk lines of level 2 reach large cities. New optional `create_world.development: { model: "geography" | "legacy" }` (default `geography` for a geography world) and an optional `resource` on geography `mine` sites. Other diagnostics: `MAP_DEVELOPMENT_PLANNED`, `MAP_PORT_LAKE_UNSUPPORTED`, `MAP_PORT_SITE_NOT_COASTAL`, `MAP_SITE_UNPLACED`.
12
+ - Accept a strait between two coasts of one land as a crossing between two provinces of that land when its straight line crosses water (`MAP_GEOGRAPHY_STRAIT_SAME_LAND` is then `info`; across no water it stays an error), and add `bay` as a sea `kind` of the geography model (ADR 0062). Public schema change: one more allowed value of `seas[].kind` (minor version).
13
+ - Public schema change: new optional fields `development` and `autoStraits` on `create_world` and `resource` on geography sites (minor version at release; the schemas are regenerated; compatibility review: additive optional input only, no output field changed, behaviour changes only for worlds built from a geography).
14
+ - Stop reporting the validation findings of a map that a failed `create_world` left in place: a world that cannot generate now returns only its own blockers, and a successful replacement of a map is validated on the map it produces instead of being compared with the map it replaces (ADR 0070). Before, a river that could not reach its mouth came back with three `MAP_VALIDATION_OPERATION_UNOWNED` blockers about the old map.
15
+ - Let drawn rivers carve their valleys (ADR 0071). A river whose course crosses a divide higher than 22 height units (a volcano cone, a foothill, the tip of a range) is no longer refused: its bed is cut through the rise as a gorge, and each crossing is reported as the warning `MAP_RIVER_CARVED_CROSSING` with the river, climb and divide location (world pixel, height, nearest range or volcano). Models that were refused now write; models that wrote are unchanged.
16
+ - Report every named river with an impossible course in one run: `MAP_RIVER_UNREACHABLE_MOUTH` (a mouth on another landmass, a source or via point that cannot be reached, a lake without a shore) keeps its code and top-level fields and gains `details.rivers`, each with its reason or `blockedBy` another failed river.
17
+ - Add `create_world.synthesisCheck` (with `preview: true` and a geography): run only the relief and river synthesis and report all impossible rivers and carved crossings without writing (`MAP_SYNTHESIS_CHECK_READY`, `MAP_SYNTHESIS_CHECK_BLOCKED`, `data.synthesisCheck`). Public schema change: one new optional field on `create_world` (minor version).
18
+
19
+ ## 4.5.0 - 2026-10-25
20
+
21
+ - Refuse `hoi4.focus_rewrite` with `FOCUS_SOURCE_CHANGED_SINCE_PLAN` when the focus source changed in meaning after the plan was inspected, instead of reverting the external change to the plan's values (national trees and continuous palettes). Comment and formatting changes and a source that already equals the plan are still accepted. Reported by the HOI4 Factory S01 EXP-02 probe.
22
+ - Refuse every rewrite of a file that is not valid UTF-8 and contains bytes Windows-1252 cannot represent (0x81, 0x8D, 0x8F, 0x90, 0x9D) with `SOURCE_ENCODING_NOT_ROUND_TRIPPABLE`. Previously the encoder wrote 0x9D for each of them without a diagnostic. Reads and inspection continue and add a warning with the same code. Valid UTF-8 and valid Windows-1252 files are unchanged.
23
+ - Place countries the mod already owns (ADR 0069): `create_world.countries` accepts `{ "source": "workspace", "realms": [...] }` besides the array. Each realm is a declared tag with a capital point and optional seeds, claim and exclusion polygons and a weight; `unowned` polygons and `islands` (`nearest`, `one-owner`, `unowned`) decide land no country claims. The world then writes only the map, states, strategic regions and their names, and none of the countries' files. New refusals `MAP_WORLD_REALM_TAG_UNKNOWN`, `MAP_WORLD_REALM_TAG_DYNAMIC`, `MAP_WORLD_REALM_POINT_OUTSIDE`, `MAP_WORLD_REALM_POINT_WATER`, `MAP_WORLD_REALM_POINT_CONFLICT` and `MAP_WORLD_REALM_CLAIM_OVERLAP`; notes `MAP_WORLD_REALMS_PLACED` and `MAP_WORLD_STATE_UNOWNED`; result field `data.realms` and artifact `map-world-realms.json`. Public schema change: `countries` becomes a union of the existing array and the new object (minor version). Requests with the array are unchanged.
24
+ - Report what a world write does to the previous world's files: `MAP_WORLD_OUTPUTS_RECONCILED`, `data.outputs` (written, retired and released counts with up to 200 paths each) and the artifact `map-world-outputs.json`. With workspace countries an edited country file of the previous world is released to the mod (`MAP_WORLD_OUTPUT_RELEASED`) instead of refusing the write; every other edited output still refuses with `MANAGED_OUTPUT_EDITED`.
25
+ - Report the declared country tags after every world write (`MAP_WORLD_TAG_COUNT`) and warn above 439 (`MAP_WORLD_TAG_COUNT_HIGH`), the count at which one measured game build overflowed its flag sheet.
26
+
3
27
  ## 4.4.0 - 2026-10-24
4
28
 
5
29
  - 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).
@@ -16,6 +16,10 @@ const manifestSchema = z
16
16
  .strict();
17
17
  /** Pure planning only: the shared transaction engine performs every replacement and deletion. */
18
18
  export function reconcileManagedOutputs(input) {
19
+ return planManagedOutputs(input).changes;
20
+ }
21
+ /** Plans the managed-output reconciliation and reports what it writes, retires and releases. */
22
+ export function planManagedOutputs(input) {
19
23
  const sources = new Map(input.sources
20
24
  .filter((f) => f.rootKind === 'mod')
21
25
  .map((f) => [f.relativePath.replaceAll('\\', '/').toLowerCase(), f]));
@@ -32,6 +36,9 @@ export function reconcileManagedOutputs(input) {
32
36
  throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory is invalid');
33
37
  }
34
38
  })();
39
+ const outputs = input.proposed.filter((f) => f.content !== null && input.permitsPath(f.relativePath));
40
+ const next = new Set(outputs.map((f) => f.relativePath.toLowerCase()));
41
+ const released = new Set();
35
42
  const seen = new Set();
36
43
  for (const file of previous) {
37
44
  const key = file.relativePath.toLowerCase();
@@ -43,19 +50,26 @@ export function reconcileManagedOutputs(input) {
43
50
  throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory contains an unsupported or duplicate path');
44
51
  seen.add(key);
45
52
  const current = sources.get(key);
46
- if (current !== undefined && sha256Bytes(current.bytes) !== file.sha256)
47
- throw new ServiceError('MANAGED_OUTPUT_EDITED', 'A generated file has been edited; regeneration would discard those changes', { relativePath: file.relativePath });
53
+ if (current === undefined || sha256Bytes(current.bytes) === file.sha256)
54
+ continue;
55
+ if (!next.has(key) && input.releasable?.(file.relativePath) === true) {
56
+ released.add(key);
57
+ continue;
58
+ }
59
+ throw new ServiceError('MANAGED_OUTPUT_EDITED', 'A generated file has been edited; regeneration would discard those changes', { relativePath: file.relativePath });
48
60
  }
49
- const outputs = input.proposed.filter((f) => f.content !== null && input.permitsPath(f.relativePath));
50
- const next = new Set(outputs.map((f) => f.relativePath.toLowerCase()));
51
61
  const deletions = previous
52
- .filter((f) => !next.has(f.relativePath.toLowerCase()) && sources.has(f.relativePath.toLowerCase()))
62
+ .filter((f) => {
63
+ const key = f.relativePath.toLowerCase();
64
+ return !next.has(key) && !released.has(key) && sources.has(key);
65
+ })
53
66
  .map((f) => ({
54
67
  relativePath: f.relativePath,
55
68
  content: null,
56
69
  operationIds: [input.operationId],
57
70
  }));
58
- return [
71
+ const sorted = (paths) => [...paths].sort(compareCodeUnits);
72
+ const changes = [
59
73
  ...deletions,
60
74
  {
61
75
  relativePath: input.manifestPath,
@@ -69,4 +83,10 @@ export function reconcileManagedOutputs(input) {
69
83
  mediaType: 'application/json',
70
84
  },
71
85
  ];
86
+ return {
87
+ changes,
88
+ written: sorted(outputs.map((f) => f.relativePath)),
89
+ retired: sorted(deletions.map((f) => f.relativePath)),
90
+ released: sorted(previous.filter((f) => released.has(f.relativePath.toLowerCase())).map((f) => f.relativePath)),
91
+ };
72
92
  }
@@ -1,6 +1,7 @@
1
1
  import iconv from 'iconv-lite';
2
2
  import { ServiceError } from '../result.js';
3
3
  const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]);
4
+ const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd);
4
5
  function detectNewline(text) {
5
6
  const crlf = text.indexOf('\r\n');
6
7
  const lf = text.indexOf('\n');
@@ -22,10 +23,55 @@ function isUtf8(bytes) {
22
23
  return false;
23
24
  }
24
25
  }
26
+ let windows1252RoundTrip;
27
+ function windows1252Table() {
28
+ windows1252RoundTrip ??= Array.from({ length: 256 }, (_, value) => {
29
+ const original = Buffer.from([value]);
30
+ const decoded = iconv.decode(original, 'windows-1252');
31
+ return (decoded !== REPLACEMENT_CHARACTER && iconv.encode(decoded, 'windows-1252').equals(original));
32
+ });
33
+ return windows1252RoundTrip;
34
+ }
35
+ function findWindows1252Loss(bytes) {
36
+ const table = windows1252Table();
37
+ let count = 0;
38
+ let firstOffset = -1;
39
+ const seen = new Set();
40
+ for (let offset = 0; offset < bytes.length; offset += 1) {
41
+ const value = bytes[offset];
42
+ if (table[value])
43
+ continue;
44
+ if (firstOffset < 0)
45
+ firstOffset = offset;
46
+ count += 1;
47
+ seen.add(value);
48
+ }
49
+ if (count === 0)
50
+ return undefined;
51
+ return { count, firstOffset, byteValues: [...seen].sort((left, right) => left - right) };
52
+ }
53
+ /**
54
+ * Refuses a mutation of a decoded source that cannot be re-encoded to its original bytes.
55
+ * Reads of such a source continue to work; only writes are blocked.
56
+ */
57
+ export function assertSourceEncodingPreserved(decoded, sourcePath) {
58
+ const loss = decoded.encodingLoss;
59
+ if (loss === undefined)
60
+ return;
61
+ const values = loss.byteValues.map((value) => `0x${value.toString(16).toUpperCase()}`);
62
+ throw new ServiceError('SOURCE_ENCODING_NOT_ROUND_TRIPPABLE', `Source is not valid UTF-8 and contains ${loss.count} byte(s) (${values.join(', ')}) that Windows-1252 cannot represent; editing it would silently rewrite them. Convert the file to UTF-8 or replace those bytes, then retry`, {
63
+ path: sourcePath,
64
+ encoding: decoded.encoding,
65
+ byteCount: loss.count,
66
+ byteValues: values,
67
+ firstByteOffset: loss.firstOffset,
68
+ });
69
+ }
25
70
  export function decodeSource(input) {
26
71
  const bytes = Buffer.from(input);
27
72
  let encoding;
28
73
  let text;
74
+ let encodingLoss;
29
75
  if (bytes.subarray(0, 3).equals(utf8Bom)) {
30
76
  encoding = 'utf8-bom';
31
77
  text = bytes.subarray(3).toString('utf8');
@@ -37,16 +83,24 @@ export function decodeSource(input) {
37
83
  else {
38
84
  encoding = 'windows-1252';
39
85
  text = iconv.decode(bytes, 'windows-1252');
86
+ encodingLoss = findWindows1252Loss(bytes);
40
87
  }
41
- return { encoding, text, bytes, newline: detectNewline(text) };
88
+ return {
89
+ encoding,
90
+ text,
91
+ bytes,
92
+ newline: detectNewline(text),
93
+ ...(encodingLoss === undefined ? {} : { encodingLoss }),
94
+ };
42
95
  }
43
96
  export function encodeSource(text, encoding) {
44
97
  if (encoding === 'utf8')
45
98
  return Buffer.from(text, 'utf8');
46
99
  if (encoding === 'utf8-bom')
47
100
  return Buffer.concat([utf8Bom, Buffer.from(text, 'utf8')]);
101
+ // U+FFFD is what undefined Windows-1252 bytes decode to; iconv-lite would write it back as 0x9D.
48
102
  const encoded = iconv.encode(text, 'windows-1252');
49
- if (iconv.decode(encoded, 'windows-1252') !== text) {
103
+ if (text.includes(REPLACEMENT_CHARACTER) || iconv.decode(encoded, 'windows-1252') !== text) {
50
104
  throw new ServiceError('SOURCE_ENCODING_UNREPRESENTABLE', 'The edit introduces characters that cannot be represented in the source encoding', { encoding });
51
105
  }
52
106
  return encoded;
@@ -2,7 +2,7 @@ import { hasBlockingDiagnostics } from '../diagnostics.js';
2
2
  import { ServiceError } from '../result.js';
3
3
  import { sha256Bytes } from '../canonical.js';
4
4
  import { sourceDocumentCacheKey, sourceDocuments } from './cache.js';
5
- import { decodeSource, encodeSource } from './encoding.js';
5
+ import { assertSourceEncodingPreserved, decodeSource, encodeSource, } from './encoding.js';
6
6
  import { createSourceLineIndex, isTrivia, lexClausewitz, locationFor, } from './lexer.js';
7
7
  import { SOURCE_LINE_LIMIT, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, SourceDiagnosticCollector, } from './limits.js';
8
8
  function scalarValue(token) {
@@ -298,6 +298,20 @@ function parseClausewitzUncached(bytes, sourcePath) {
298
298
  sourceRevision: sha256Bytes(decoded.bytes),
299
299
  };
300
300
  }
301
+ const encodingLoss = decoded.encodingLoss;
302
+ if (encodingLoss !== undefined) {
303
+ diagnostics.add(() => ({
304
+ code: 'SOURCE_ENCODING_NOT_ROUND_TRIPPABLE',
305
+ severity: 'warning',
306
+ category: 'syntax',
307
+ message: 'Source is not valid UTF-8 and contains bytes that Windows-1252 cannot represent; it can be read but any rewrite is refused',
308
+ location: locationFor(sourcePath, lineIndex, encodingLoss.firstOffset, encodingLoss.firstOffset + 1),
309
+ details: {
310
+ byteCount: encodingLoss.count,
311
+ byteValues: encodingLoss.byteValues.map((value) => `0x${value.toString(16).toUpperCase()}`),
312
+ },
313
+ }));
314
+ }
301
315
  const lexed = lexClausewitz(decoded.text, sourcePath, { lineIndex, diagnostics });
302
316
  const parser = new Parser(lexed.tokens, decoded.text, sourcePath, lineIndex, diagnostics);
303
317
  const root = parser.parseRoot();
@@ -323,6 +337,7 @@ export function assertRewriteSafe(document) {
323
337
  }
324
338
  }
325
339
  export function encodeDocumentText(document, text) {
340
+ assertSourceEncodingPreserved(document, document.path);
326
341
  return encodeSource(text, document.encoding);
327
342
  }
328
343
  export function assignments(block, key) {
@@ -72,6 +72,40 @@ export function assertFocusPlanAuthority(drift, authority) {
72
72
  currentPlanHash: drift.currentPlanHash,
73
73
  });
74
74
  }
75
+ /**
76
+ * True when the source changed in meaning after the plan was imported, so regenerating from the
77
+ * plan would overwrite those changes. A formatting-only change (the source still imports to the
78
+ * plan's original baseline), a source that already equals the plan, and an unchanged source are not
79
+ * semantic drift. This is deliberately independent of whether the plan itself was edited: a plan
80
+ * edit plus an external formatting change reports `conflict`, which is still safe to apply.
81
+ */
82
+ export function hasSemanticSourceDrift(drift) {
83
+ if (!drift.sourceChanged)
84
+ return false;
85
+ if (drift.status === 'tree_removed')
86
+ return true;
87
+ return (drift.currentPlanHash !== drift.importedPlanHash &&
88
+ drift.currentPlanHash !== drift.savedPlanHash);
89
+ }
90
+ /**
91
+ * Refuses to regenerate a tree from a plan when the source changed in meaning after the plan was
92
+ * imported. Applying the plan would overwrite those external changes with stale plan values.
93
+ * Re-import the source with the inspect tool, reapply the intended change to the fresh plan, and
94
+ * retry.
95
+ */
96
+ export function assertNoSemanticSourceDrift(drift, sourcePath) {
97
+ if (!hasSemanticSourceDrift(drift))
98
+ return;
99
+ throw new ServiceError('FOCUS_SOURCE_CHANGED_SINCE_PLAN', 'The focus source changed in meaning after the plan was imported; rewriting from the plan would overwrite those changes. Import the current source with hoi4.focus_inspect, reapply the intended edit to the fresh plan, and retry', {
100
+ relativePath: sourcePath,
101
+ status: drift.status,
102
+ savedSourceHash: drift.savedSourceHash,
103
+ currentSourceHash: drift.currentSourceHash,
104
+ savedPlanHash: drift.savedPlanHash,
105
+ currentPlanHash: drift.currentPlanHash,
106
+ importedPlanHash: drift.importedPlanHash,
107
+ });
108
+ }
75
109
  export function detectContinuousFocusDrift(savedPlan, currentDocument) {
76
110
  const currentSourceHash = sha256Bytes(currentDocument.bytes);
77
111
  const savedPlanHash = focusPlanHash(savedPlan);
@@ -5,7 +5,7 @@ import { ServiceError } from '../core/result.js';
5
5
  import { parseClausewitz } from '../core/source/index.js';
6
6
  import { TRANSACTION_MAX_DIAGNOSTICS } from '../core/transaction-limits.js';
7
7
  import { PACKAGE_VERSION } from '../version.js';
8
- import { assertFocusPlanAuthority, detectContinuousFocusDrift, detectFocusDrift, } from './drift.js';
8
+ import { assertFocusPlanAuthority, assertNoSemanticSourceDrift, detectContinuousFocusDrift, detectFocusDrift, } from './drift.js';
9
9
  import { importContinuousFocusPalettes, importFocusTrees } from './importer.js';
10
10
  import { layoutFocusTree, layoutFocusTreeAsync } from './layout.js';
11
11
  import { lintContinuousFocusPalette, lintFocusTree } from './lint.js';
@@ -191,8 +191,11 @@ export class FocusWorkbench {
191
191
  const drift = targetCreated
192
192
  ? missingTargetDrift(input.plan, document)
193
193
  : detectFocusDrift(input.plan, document, input.references);
194
- if (!targetCreated)
194
+ if (!targetCreated) {
195
+ if (input.onSemanticSourceDrift === 'refuse')
196
+ assertNoSemanticSourceDrift(drift, input.relativePath);
195
197
  assertFocusPlanAuthority(drift, input.authority ?? 'plan');
198
+ }
196
199
  const layout = input.layout ??
197
200
  (await layoutFocusTreeAsync(input.plan, input.signal === undefined ? {} : { signal: input.signal }));
198
201
  const after = updateFocusTreeSource(document, targetCreated ? undefined : drift.currentSourcePlan, input.plan, layout);
@@ -414,8 +417,11 @@ export class FocusWorkbench {
414
417
  const drift = targetCreated
415
418
  ? missingTargetDrift(input.plan, document)
416
419
  : detectContinuousFocusDrift(input.plan, document);
417
- if (!targetCreated)
420
+ if (!targetCreated) {
421
+ if (input.onSemanticSourceDrift === 'refuse')
422
+ assertNoSemanticSourceDrift(drift, input.relativePath);
418
423
  assertFocusPlanAuthority(drift, input.authority ?? 'plan');
424
+ }
419
425
  const after = updateContinuousFocusPaletteSource(document, targetCreated ? undefined : drift.currentSourcePlan, input.plan);
420
426
  const afterHash = sha256Bytes(after);
421
427
  const operationId = deterministicId('continuous_focus_change', {
@@ -147,6 +147,7 @@ export async function prepareFocusRewrite(engine, workbench, input, context) {
147
147
  plan,
148
148
  createIfMissing,
149
149
  authority: 'plan',
150
+ onSemanticSourceDrift: 'refuse',
150
151
  ...(context.principal === undefined ? {} : { principal: context.principal }),
151
152
  artifacts: reviewArtifacts,
152
153
  readDependencies: readDependenciesFromScannedFiles(snapshot.files),
@@ -301,6 +302,7 @@ export async function prepareFocusRewrite(engine, workbench, input, context) {
301
302
  plan,
302
303
  createIfMissing,
303
304
  authority: 'plan',
305
+ onSemanticSourceDrift: 'refuse',
304
306
  layout: proposed.layout,
305
307
  ...(context.principal === undefined ? {} : { principal: context.principal }),
306
308
  index: snapshot.index,
@@ -25,9 +25,11 @@ export const BORDER_FEATURE_BITS = {
25
25
  };
26
26
  const BORDER_FEATURES = Object.keys(BORDER_FEATURE_BITS);
27
27
  /** Barrier set: the plan's measurement (rivers, steep gradients) plus lake shores and extras. */
28
- const BARRIER_MASK = 1 | 2 | 4 | 16;
28
+ export const BORDER_BARRIER_MASK = 1 | 2 | 4 | 16;
29
+ const BARRIER_MASK = BORDER_BARRIER_MASK;
29
30
  /** Natural set: the barrier set plus terrain-class changes. */
30
- const NATURAL_MASK = 31;
31
+ export const BORDER_NATURAL_MASK = 31;
32
+ const NATURAL_MASK = BORDER_NATURAL_MASK;
31
33
  /** Province kinds used by the metric. Only land provinces have land borders. */
32
34
  export const BORDER_PROVINCE_KIND = { unknown: 0, land: 1, sea: 2, lake: 3 };
33
35
  /** Targets for an engineered world: the design's thresholds, measured against vanilla. */
@@ -374,13 +376,16 @@ function validateInput(input) {
374
376
  if (planes.terrain !== undefined && planes.terrain.classes.length !== 256)
375
377
  throw new ServiceError('MAP_BORDER_QUALITY_INPUT_INVALID', 'The terrain class table needs 256 entries');
376
378
  }
377
- /** One streamed pass over the raster: collect every land border edge and the chance baseline. */
378
- export async function buildBorderEdgeTable(input, options = {}) {
379
+ /**
380
+ * One streamed pass over the raster: collect every land border edge and the chance baseline. The
381
+ * pass is a generator that yields every 16 rows so a synchronous planner can interleave its own
382
+ * checkpoints; `buildBorderEdgeTable` drives it and yields to the event loop.
383
+ */
384
+ export function* buildBorderEdgeTableSteps(input, options = {}) {
379
385
  const resolved = resolveOptions(options);
380
386
  validateInput(input);
381
387
  const { width, height, provinceIds, provinceKinds } = input;
382
388
  const rows = new FeatureRows(input, resolved);
383
- const pace = new Pace(resolved.signal);
384
389
  let capacity = Math.min(65_536, resolved.maxEdges);
385
390
  let keys = new Int32Array(capacity);
386
391
  let provinceA = new Int32Array(capacity);
@@ -446,8 +451,10 @@ export async function buildBorderEdgeTable(input, options = {}) {
446
451
  }
447
452
  };
448
453
  for (let y = 0; y < height; y += 1) {
449
- if (y % 16 === 0)
450
- await pace.tick();
454
+ if (y % 16 === 0) {
455
+ resolved.signal?.throwIfAborted();
456
+ yield;
457
+ }
451
458
  if (y + 1 < height)
452
459
  rows.rowFlags(y + 1, next);
453
460
  const rowStart = y * width;
@@ -506,6 +513,17 @@ export async function buildBorderEdgeTable(input, options = {}) {
506
513
  options: resolved,
507
514
  };
508
515
  }
516
+ /** Drive `buildBorderEdgeTableSteps`, yielding to the event loop about every 12 milliseconds. */
517
+ export async function buildBorderEdgeTable(input, options = {}) {
518
+ const steps = buildBorderEdgeTableSteps(input, options);
519
+ const pace = new Pace(options.signal);
520
+ for (;;) {
521
+ const step = steps.next();
522
+ if (step.done)
523
+ return step.value;
524
+ await pace.tick();
525
+ }
526
+ }
509
527
  function round(value, places = 4) {
510
528
  const scale = 10 ** places;
511
529
  return Math.round(value * scale) / scale;
@@ -623,7 +623,7 @@ function shortestRailPath(neighbors, sources, isTarget, cost) {
623
623
  return path;
624
624
  }
625
625
  for (const next of neighbors(province)) {
626
- const candidate = current + cost(next);
626
+ const candidate = current + cost(province, next);
627
627
  if (candidate < (distance.get(next) ?? Number.POSITIVE_INFINITY)) {
628
628
  distance.set(next, candidate);
629
629
  previous.set(next, province);
@@ -726,12 +726,25 @@ export function planSupplyRebuild(index, request) {
726
726
  const pending = new Set(ownerScope
727
727
  .map((state) => state.provinces.find((id) => nodes.has(id)))
728
728
  .filter((id) => id !== undefined && !connected.has(id)));
729
+ // Trunk lines reach the major cities of the country even where no supply node stands.
730
+ const trunk = new Set();
731
+ for (const id of request.railCost?.trunk ?? [])
732
+ if (ownerOf(id) === owner && !connected.has(id) && neighbors(id).length > 0) {
733
+ trunk.add(id);
734
+ pending.add(id);
735
+ }
736
+ const ownerFactor = (id) => (ownerOf(id) === owner ? 1 : 4);
737
+ const stepCost = (from, to) => (request.railCost?.cost(from, to) ?? 1) * ownerFactor(to);
729
738
  while (pending.size > 0) {
730
- const path = shortestRailPath(neighbors, connected, (id) => pending.has(id), (id) => (ownerOf(id) === owner ? 1 : 4));
739
+ const path = shortestRailPath(neighbors, connected, (id) => pending.has(id), stepCost);
731
740
  if (path === undefined)
732
741
  break;
733
742
  // A path that leaves the connected set once: from its target back to the network.
734
- const level = path.includes(hub) ? capitalLevel : railwayLevel;
743
+ const level = path.includes(hub)
744
+ ? capitalLevel
745
+ : trunk.has(path[0])
746
+ ? Math.max(railwayLevel, request.trunkRailwayLevel ?? 2)
747
+ : railwayLevel;
735
748
  newRailways.push({ level, provinces: [...path].reverse() });
736
749
  railways.push(path);
737
750
  for (const id of path) {