hoi4-agent-tools 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +33 -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/managed-outputs.js +26 -6
  5. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  6. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  7. package/dist/hoi4_agent_tools/core/source/parser.js +16 -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/focus/drift.js +34 -0
  14. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  15. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  16. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  17. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  18. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  19. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  20. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  21. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  22. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  23. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  24. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  25. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  26. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  27. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  28. package/dist/hoi4_agent_tools/map/model.js +17 -3
  29. package/dist/hoi4_agent_tools/map/operations.js +284 -14
  30. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  31. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  32. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  33. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  34. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  35. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  36. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  37. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  38. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
  40. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  41. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  42. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  43. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  44. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  45. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  46. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  47. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  48. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  49. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  50. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  51. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  52. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  53. package/dist/hoi4_agent_tools/map/render.js +36 -0
  54. package/dist/hoi4_agent_tools/map/service.js +206 -16
  55. package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
  56. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  57. package/dist/hoi4_agent_tools/map/water.js +227 -0
  58. package/dist/hoi4_agent_tools/map/world-context.js +47 -1
  59. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  60. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  61. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  62. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  63. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  64. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  65. package/dist/hoi4_agent_tools/map/world.js +562 -163
  66. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  67. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  68. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  69. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  70. package/dist/hoi4_agent_tools/schemas/map.js +280 -15
  71. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  72. package/dist/hoi4_agent_tools/version.js +1 -1
  73. package/docs/configuration.md +17 -14
  74. package/docs/focus.md +6 -0
  75. package/docs/map.md +292 -15
  76. package/docs/tools.md +4 -4
  77. package/docs/troubleshooting.md +11 -11
  78. package/npm-shrinkwrap.json +109 -91
  79. package/package.json +3 -3
  80. package/schemas/configuration.schema.json +65 -58
  81. package/schemas/continuous-focus-palette.schema.json +1 -1
  82. package/schemas/custom-weighted-pool.schema.json +1 -1
  83. package/schemas/decision-inspect-input.schema.json +1 -1
  84. package/schemas/error-log-input.schema.json +1 -1
  85. package/schemas/event-feature-manifest.schema.json +1 -1
  86. package/schemas/focus-plan.schema.json +1 -1
  87. package/schemas/focus-planning-sidecar.schema.json +1 -1
  88. package/schemas/geography-model.schema.json +1148 -0
  89. package/schemas/gui-animation-source.schema.json +1 -1
  90. package/schemas/gui-helper.schema.json +5 -11
  91. package/schemas/gui-scenario.schema.json +1 -1
  92. package/schemas/helper-expansion-request.schema.json +1 -1
  93. package/schemas/helper-expansion-summary.schema.json +1 -1
  94. package/schemas/impact-inspect-input.schema.json +1 -1
  95. package/schemas/map-operation.schema.json +2325 -292
  96. package/schemas/mechanic-test-input.schema.json +1 -1
  97. package/schemas/mod-index-input.schema.json +1 -1
  98. package/schemas/operation-result.schema.json +1 -1
  99. package/schemas/package-check-input.schema.json +1 -1
  100. package/schemas/package-manifest.schema.json +1 -1
  101. package/schemas/probability-analysis-result.schema.json +25 -7
  102. package/schemas/probability-compare-input.schema.json +1 -1
  103. package/schemas/probability-evaluate-input.schema.json +1 -1
  104. package/schemas/probability-inspect-input.schema.json +1 -1
  105. package/schemas/probability-render-input.schema.json +1 -1
  106. package/schemas/probability-scenario-set.schema.json +1 -1
  107. package/schemas/probability-sequence-input.schema.json +1 -1
  108. package/schemas/probability-simulate-input.schema.json +1 -1
  109. package/schemas/probability-sweep-input.schema.json +1 -1
  110. package/schemas/reference-context-input.schema.json +1 -1
  111. package/schemas/reference-read-input.schema.json +1 -1
  112. package/schemas/reference-search-input.schema.json +1 -1
  113. package/schemas/reference-sheets.schema.json +267 -0
  114. package/schemas/scenario-suite.schema.json +1 -1
  115. package/schemas/scenario-test-input.schema.json +1 -1
  116. package/schemas/script-validate-input.schema.json +1 -1
  117. package/schemas/shared-scenario.schema.json +1 -1
  118. package/schemas/source-lookup-input.schema.json +1 -1
  119. package/server.json +2 -2
@@ -0,0 +1,34 @@
1
+ /** Deterministic value noise shared by world generation. Only + - * / are used, so the output is
2
+ * identical on every platform. */
3
+ export function lattice(seed, x, y) {
4
+ let hash = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 982451653);
5
+ hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
6
+ return ((hash ^ (hash >>> 16)) >>> 0) / 4294967296;
7
+ }
8
+ export function smooth(t) {
9
+ return t * t * (3 - 2 * t);
10
+ }
11
+ export function valueNoise(seed, x, y) {
12
+ const x0 = Math.floor(x);
13
+ const y0 = Math.floor(y);
14
+ const tx = smooth(x - x0);
15
+ const ty = smooth(y - y0);
16
+ const a = lattice(seed, x0, y0);
17
+ const b = lattice(seed, x0 + 1, y0);
18
+ const c = lattice(seed, x0, y0 + 1);
19
+ const d = lattice(seed, x0 + 1, y0 + 1);
20
+ return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
21
+ }
22
+ export function fractal(seed, x, y, scale, octaves = 5) {
23
+ let total = 0;
24
+ let amplitude = 1;
25
+ let weight = 0;
26
+ let frequency = 1 / scale;
27
+ for (let octave = 0; octave < octaves; octave += 1) {
28
+ total += valueNoise(seed + octave * 101, x * frequency, y * frequency) * amplitude;
29
+ weight += amplitude;
30
+ amplitude *= 0.5;
31
+ frequency *= 2;
32
+ }
33
+ return total / weight;
34
+ }
@@ -1,7 +1,25 @@
1
1
  import path from 'node:path';
2
- import { reconcileManagedOutputs } from '../core/managed-outputs.js';
2
+ import { planManagedOutputs } from '../core/managed-outputs.js';
3
3
  export const WORLD_OUTPUT_MANIFEST = '.hoi4-agent-world.json';
4
+ /** Country outputs of a generated world: tags, country files, histories, units, flags, bookmark. */
5
+ export function isWorldCountryOutput(relativePath) {
6
+ const dir = path.posix.dirname(relativePath), file = path.posix.basename(relativePath);
7
+ return ((['common/countries', 'history/countries'].includes(dir) &&
8
+ /^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
9
+ (dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
10
+ (['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
11
+ /^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
12
+ [
13
+ 'common/countries/colors.txt',
14
+ 'common/country_tags/zz_world_countries.txt',
15
+ 'common/bookmarks/world.txt',
16
+ ].includes(relativePath));
17
+ }
4
18
  export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots) {
19
+ return planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots)
20
+ .changes;
21
+ }
22
+ export function planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots, options = {}) {
5
23
  const mapFiles = new Set([
6
24
  'provinces.bmp',
7
25
  'definition.csv',
@@ -29,15 +47,8 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
29
47
  (dir === `${mapRoot}/terrain` && /^colormap_[a-z0-9_]+\.dds$/u.test(file)) ||
30
48
  (dir === `${mapRoot}/strategicregions` && /^\d+.*\.txt$/u.test(file)) ||
31
49
  (stateRoots.includes(dir) && /^\d+.*\.txt$/u.test(file)) ||
32
- (['common/countries', 'history/countries'].includes(dir) &&
33
- /^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
34
- (dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
35
- (['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
36
- /^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
50
+ isWorldCountryOutput(name) ||
37
51
  [
38
- 'common/countries/colors.txt',
39
- 'common/country_tags/zz_world_countries.txt',
40
- 'common/bookmarks/world.txt',
41
52
  'common/on_actions/world.txt',
42
53
  'common/ai_areas/world.txt',
43
54
  'common/ai_strategy_plans/world.txt',
@@ -45,11 +56,12 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
45
56
  localisationRoots.some((root) => name === `${root}/english/world_l_english.yml` ||
46
57
  name === `${root}/replace/world_l_english.yml`));
47
58
  };
48
- return reconcileManagedOutputs({
59
+ return planManagedOutputs({
49
60
  manifestPath: WORLD_OUTPUT_MANIFEST,
50
61
  sources,
51
62
  proposed,
52
63
  operationId,
53
64
  permitsPath,
65
+ ...(options.releaseCountryOutputs === true ? { releasable: isWorldCountryOutput } : {}),
54
66
  });
55
67
  }
@@ -0,0 +1,119 @@
1
+ import { PARTITION_KIND, PARTITION_TERRAIN, partitionFieldsFromBitmaps, } from './partition-fields.js';
2
+ import { PARTITION_PROVINCE_ERROR, PARTITION_PROVINCE_WARNING, } from './partition-tuning.js';
3
+ import { partitionFieldsFromGeography, partitionFieldsWithTerrainLines, } from './partition-geography.js';
4
+ /** Terrain class of each reference terrain code (`referenceTerrainNames` in `world.ts`). */
5
+ const REFERENCE_CLASSES = [
6
+ PARTITION_TERRAIN.plains,
7
+ PARTITION_TERRAIN.forest,
8
+ PARTITION_TERRAIN.desert,
9
+ PARTITION_TERRAIN.mountain,
10
+ PARTITION_TERRAIN.snow,
11
+ PARTITION_TERRAIN.mountain,
12
+ PARTITION_TERRAIN.hills,
13
+ PARTITION_TERRAIN.marsh,
14
+ PARTITION_TERRAIN.jungle,
15
+ ];
16
+ /** Value below which a share of the land lies, from a coarse histogram (no sort of 13 million). */
17
+ function landQuantiles(input, shares) {
18
+ const bins = 2048;
19
+ const histogram = new Uint32Array(bins);
20
+ let land = 0;
21
+ for (let p = 0; p < input.kind.length; p++) {
22
+ if (input.kind[p] !== PARTITION_KIND.land)
23
+ continue;
24
+ const value = Math.min(1, Math.max(0, input.elevation[p]));
25
+ histogram[Math.min(bins - 1, Math.floor(value * bins))]++;
26
+ land++;
27
+ }
28
+ return shares.map((share) => {
29
+ let seen = 0;
30
+ for (let bin = 0; bin < bins; bin++) {
31
+ seen += histogram[bin];
32
+ if (seen >= share * land)
33
+ return (bin + 1) / bins;
34
+ }
35
+ return 1;
36
+ });
37
+ }
38
+ /**
39
+ * Partition fields from the generator's own rasters: heights as `heightmap.bmp` will hold them,
40
+ * the drainage rivers, and a per-pixel terrain class (the reference picture's when there is one,
41
+ * otherwise elevation and moisture rules like the province terrain rules).
42
+ */
43
+ export function surfacePartitionFields(input) {
44
+ const { width, height, kind, elevation, moisture } = input;
45
+ const total = width * height;
46
+ const heights = new Uint8Array(total);
47
+ const terrain = new Uint8Array(total).fill(PARTITION_TERRAIN.water);
48
+ const wet = new Uint8Array(total);
49
+ const [hillLevel = 1, mountainLevel = 1] = input.referenceTerrain === undefined ? landQuantiles(input, [0.72, 0.88]) : [1, 1];
50
+ for (let p = 0; p < total; p++) {
51
+ wet[p] = Math.round(Math.min(1, Math.max(0, moisture[p])) * 255);
52
+ if (kind[p] !== PARTITION_KIND.land) {
53
+ heights[p] = 89;
54
+ continue;
55
+ }
56
+ const value = elevation[p];
57
+ heights[p] =
58
+ input.heights === undefined
59
+ ? Math.min(255, Math.max(96, Math.round(96 + value * 110)))
60
+ : Math.max(96, input.heights[p]);
61
+ if (input.referenceTerrain !== undefined) {
62
+ terrain[p] = REFERENCE_CLASSES[input.referenceTerrain[p]] ?? PARTITION_TERRAIN.plains;
63
+ continue;
64
+ }
65
+ const y = Math.floor(p / width);
66
+ const latitude = Math.abs(y / height - 0.5) * 2;
67
+ const m = moisture[p];
68
+ terrain[p] =
69
+ value >= mountainLevel
70
+ ? PARTITION_TERRAIN.mountain
71
+ : value >= hillLevel
72
+ ? PARTITION_TERRAIN.hills
73
+ : latitude < 0.3 && m > 0.55
74
+ ? PARTITION_TERRAIN.jungle
75
+ : latitude > 0.25 && latitude < 0.55 && m < 0.42
76
+ ? PARTITION_TERRAIN.desert
77
+ : m > 0.62
78
+ ? PARTITION_TERRAIN.marsh
79
+ : m > 0.5
80
+ ? PARTITION_TERRAIN.forest
81
+ : PARTITION_TERRAIN.plains;
82
+ }
83
+ const identity = Uint8Array.from({ length: 256 }, (_, index) => index);
84
+ return partitionFieldsFromBitmaps({
85
+ width,
86
+ height,
87
+ kind,
88
+ heights,
89
+ rivers: input.riverPixels,
90
+ terrain,
91
+ terrainClasses: identity,
92
+ moisture: wet,
93
+ });
94
+ }
95
+ /**
96
+ * Partition fields of a world built from a geography model: the surface fields read from the
97
+ * synthesised heights, with the model's regions, densities and wall ranges
98
+ * (`partitionFieldsFromGeography`) and the walls, fort lines and crests the synthesis drew
99
+ * (`partitionFieldsWithTerrainLines`).
100
+ */
101
+ export function geographyPartitionFields(input, geography, terrain) {
102
+ return partitionFieldsWithTerrainLines(partitionFieldsFromGeography(surfacePartitionFields(input), geography), terrain.barrier);
103
+ }
104
+ /** A province-count note for the legacy partition and the final check of the feature-aware one. */
105
+ export function provinceCountNote(provinces, mode) {
106
+ if (provinces <= PARTITION_PROVINCE_WARNING)
107
+ return undefined;
108
+ return {
109
+ code: 'MAP_PARTITION_PROVINCE_COUNT_HIGH',
110
+ severity: 'warning',
111
+ message: `The world has ${provinces} provinces. Vanilla has about 13,400 and the wiki advises at most about 21,000; the text tables (definitions, positions, buildings) near 16 MiB each stop loading beyond that. Raise landProvincePixels and seaProvincePixels to reduce the count`,
112
+ details: {
113
+ provinces,
114
+ warningAbove: PARTITION_PROVINCE_WARNING,
115
+ errorAbove: PARTITION_PROVINCE_ERROR,
116
+ mode,
117
+ },
118
+ };
119
+ }
@@ -10,15 +10,31 @@ function hash(seed, value) {
10
10
  function circularDelta(x, origin, width) {
11
11
  return x - origin - Math.round((x - origin) / width) * width;
12
12
  }
13
+ /** Extra land sites a sampling box gets from its mean height and wetness, before rounding. */
14
+ function landDensity(relief, wet) {
15
+ return Math.max(0, relief) * 1.3 + Math.max(0, wet);
16
+ }
13
17
  /**
14
18
  * Jittered sites use ordinary Euclidean distance, rather than cardinal flood distance.
15
- * Additional sites in elevated/wet land give those areas a denser, varied partition.
19
+ * Additional sites in elevated/wet land give those areas a denser, varied partition; the
20
+ * sampling box grows by the mean of those extras, so they redistribute provinces instead of
21
+ * adding to the requested `landProvincePixels`. Lakes take the sea target.
16
22
  * Each occupied sampling box has a site, including thin coasts and small islands.
17
23
  */
18
24
  function sites(input, wanted) {
19
25
  const { width, height, kind, elevation, moisture, seed } = input;
20
- const target = wanted === 0 ? input.seaProvincePixels : input.landProvincePixels;
21
- const cell = Math.max(2, Math.floor(Math.min(Math.sqrt(target), width / 20, height / 20)));
26
+ const target = wanted === 1 ? input.landProvincePixels : input.seaProvincePixels;
27
+ let extras = 0;
28
+ let pixels = 0;
29
+ if (wanted === 1)
30
+ for (let p = 0; p < kind.length; p++) {
31
+ if (kind[p] !== 1)
32
+ continue;
33
+ pixels++;
34
+ extras += Math.min(2, landDensity(elevation[p], moisture[p]));
35
+ }
36
+ const meanExtra = pixels === 0 ? 0 : extras / pixels;
37
+ const cell = Math.max(2, Math.floor(Math.min(Math.sqrt(target * (1 + meanExtra)), width / 20, height / 20)));
22
38
  const columns = Math.ceil(width / cell);
23
39
  const rows = Math.ceil(height / cell);
24
40
  const result = {
@@ -57,7 +73,7 @@ function sites(input, wanted) {
57
73
  }
58
74
  if (count === 0)
59
75
  continue;
60
- const density = wanted === 1 ? Math.max(0, relief / count) * 1.3 + Math.max(0, wet / count) : 0;
76
+ const density = wanted === 1 ? landDensity(relief / count, wet / count) : 0;
61
77
  const extra = Math.min(2, Math.floor(density + hash(seed, bin + wanted * 7717)));
62
78
  const used = new Set();
63
79
  for (let slot = 0; slot <= extra; slot++) {
@@ -152,6 +168,7 @@ function connectedParts(input, labels) {
152
168
  maxX: [],
153
169
  minY: [],
154
170
  maxY: [],
171
+ group: [],
155
172
  };
156
173
  const limitX = Math.max(1, Math.floor(width / 8));
157
174
  const limitY = Math.max(1, Math.floor(height / 8));
@@ -161,6 +178,8 @@ function connectedParts(input, labels) {
161
178
  const id = parts.parent.length;
162
179
  parts.parent.push(id);
163
180
  parts.kind.push(kind[start]);
181
+ if (input.group !== undefined)
182
+ parts.group.push(input.group[start]);
164
183
  const origin = start % width;
165
184
  let minX = 0;
166
185
  let maxX = 0;
@@ -218,13 +237,15 @@ function root(parts, id) {
218
237
  }
219
238
  return r;
220
239
  }
221
- function merge(parts, a, b, width, height) {
240
+ function merge(parts, a, b, width, height, sameGroup = false) {
222
241
  a = root(parts, a);
223
242
  b = root(parts, b);
224
243
  if (a === b)
225
244
  return true;
226
245
  if (parts.kind[a] !== parts.kind[b])
227
246
  return false;
247
+ if (sameGroup && parts.group[a] !== parts.group[b])
248
+ return false;
228
249
  const shift = parts.origin[b] - parts.origin[a];
229
250
  const minX = Math.min(parts.minX[a], parts.minX[b] + shift);
230
251
  const maxX = Math.max(parts.maxX[a], parts.maxX[b] + shift);
@@ -246,25 +267,39 @@ function merge(parts, a, b, width, height) {
246
267
  function absorbSlivers(input, parts) {
247
268
  const { width, height } = input;
248
269
  // Every successful operation removes a whole connected component. The scan is
249
- // repeated because a previously isolated fragment may become mergeable afterwards.
250
- for (let pass = 0; pass < 4; pass++) {
251
- let changed = false;
252
- for (let p = 0; p < parts.pixel.length; p++) {
253
- const x = p % width;
254
- const adjacent = [x + 1 < width ? p + 1 : p + 1 - width, p + width];
255
- for (const q of adjacent) {
256
- if (q >= parts.pixel.length)
257
- continue;
258
- const a = root(parts, parts.pixel[p]);
259
- const b = root(parts, parts.pixel[q]);
260
- if (a === b || (parts.size[a] >= 20 && parts.size[b] >= 20))
261
- continue;
262
- if (merge(parts, a, b, width, height))
263
- changed = true;
270
+ // repeated because a previously isolated fragment may become mergeable afterwards. With groups
271
+ // or lines, the first stages merge inside a group and not across a line, and the last stage
272
+ // takes what is left.
273
+ const stages = [];
274
+ if (input.group !== undefined && input.line !== undefined)
275
+ stages.push({ sameGroup: true, avoidLine: true });
276
+ if (input.line !== undefined)
277
+ stages.push({ sameGroup: false, avoidLine: true });
278
+ if (input.group !== undefined)
279
+ stages.push({ sameGroup: true, avoidLine: false });
280
+ stages.push({ sameGroup: false, avoidLine: false });
281
+ for (const { sameGroup, avoidLine } of stages) {
282
+ for (let pass = 0; pass < 4; pass++) {
283
+ let changed = false;
284
+ for (let p = 0; p < parts.pixel.length; p++) {
285
+ const x = p % width;
286
+ const adjacent = [x + 1 < width ? p + 1 : p + 1 - width, p + width];
287
+ for (const q of adjacent) {
288
+ if (q >= parts.pixel.length)
289
+ continue;
290
+ if (avoidLine && (input.line[p] !== 0 || input.line[q] !== 0))
291
+ continue;
292
+ const a = root(parts, parts.pixel[p]);
293
+ const b = root(parts, parts.pixel[q]);
294
+ if (a === b || (parts.size[a] >= 20 && parts.size[b] >= 20))
295
+ continue;
296
+ if (merge(parts, a, b, width, height, sameGroup))
297
+ changed = true;
298
+ }
264
299
  }
300
+ if (!changed)
301
+ break;
265
302
  }
266
- if (!changed)
267
- break;
268
303
  }
269
304
  }
270
305
  /** Removing a pixel may not cut its donor; verify the donor's local four-way paths. */
@@ -352,6 +387,14 @@ function rebalanceSlivers(input, parts) {
352
387
  }
353
388
  function repairCrossings(input, parts) {
354
389
  const { width, height, kind } = input;
390
+ const repairStages = [];
391
+ if (input.group !== undefined && input.line !== undefined)
392
+ repairStages.push({ sameGroup: true, avoidLine: true });
393
+ if (input.line !== undefined)
394
+ repairStages.push({ sameGroup: false, avoidLine: true });
395
+ if (input.group !== undefined)
396
+ repairStages.push({ sameGroup: true, avoidLine: false });
397
+ repairStages.push({ sameGroup: false, avoidLine: false });
355
398
  for (let pass = 0; pass < 12; pass++) {
356
399
  let changed = false;
357
400
  for (let y = 0; y + 1 < height; y++) {
@@ -369,22 +412,32 @@ function repairCrossings(input, parts) {
369
412
  [2, 3],
370
413
  ];
371
414
  let repaired = false;
372
- // Whole-cell unions cannot introduce crossings or disconnected provinces.
373
- for (const [i, j] of edges) {
374
- if (merge(parts, ids[i], ids[j], width, height)) {
375
- repaired = true;
376
- break;
415
+ // Whole-cell unions cannot introduce crossings or disconnected provinces. With groups or
416
+ // lines, the repairs inside one group and not across a line come first.
417
+ for (const { sameGroup, avoidLine } of repairStages) {
418
+ for (const [i, j] of edges) {
419
+ if (avoidLine && (input.line[corners[i]] !== 0 || input.line[corners[j]] !== 0))
420
+ continue;
421
+ if (merge(parts, ids[i], ids[j], width, height, sameGroup)) {
422
+ repaired = true;
423
+ break;
424
+ }
377
425
  }
378
- }
379
- if (!repaired) {
426
+ if (repaired)
427
+ break;
380
428
  for (const [i, j] of edges.flatMap(([i, j]) => [
381
429
  [i, j],
382
430
  [j, i],
383
431
  ])) {
384
432
  const p = corners[i];
385
433
  const q = corners[j];
434
+ if (avoidLine && (input.line[p] !== 0 || input.line[q] !== 0))
435
+ continue;
386
436
  if (kind[p] !== kind[q] || !removable(parts, p, width, height))
387
437
  continue;
438
+ if (sameGroup &&
439
+ parts.group[root(parts, ids[i])] !== parts.group[root(parts, ids[j])])
440
+ continue;
388
441
  const target = ids[j];
389
442
  const dx = (p % width) - parts.origin[target];
390
443
  const py = Math.floor(p / width);
@@ -404,6 +457,8 @@ function repairCrossings(input, parts) {
404
457
  repaired = true;
405
458
  break;
406
459
  }
460
+ if (repaired)
461
+ break;
407
462
  }
408
463
  if (!repaired)
409
464
  throw new ServiceError('MAP_WORLD_UNREPRESENTABLE_TOPOLOGY', 'The exact land/sea/lake mask cannot resolve a four-province corner while preserving connected provinces and size limits', { x, y });
@@ -416,33 +471,13 @@ function repairCrossings(input, parts) {
416
471
  throw new ServiceError('MAP_WORLD_UNREPRESENTABLE_TOPOLOGY', 'Province corner repair did not converge');
417
472
  }
418
473
  /**
419
- * Deterministic province geometry with periodic adjacency, but separate province IDs at the seam.
420
- * Native province bounding boxes use bitmap coordinates, not circular spans. Work is proportional to pixels
421
- * with bounded local candidate searches; no retained per-pixel objects or global site scans.
474
+ * Refuse a mask that needs more provinces than the engine tolerates before any site is built:
475
+ * a singleton component of one kind necessarily needs its own province, which is an inexpensive
476
+ * lower bound for a hostile fragmented mask.
422
477
  */
423
- export function generateWorldProvinces(input) {
424
- const { width, height, kind, elevation, moisture, seed } = input;
425
- if (!Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16)
426
- invalid('Province dimensions must be integers of at least 16 pixels');
427
- const total = width * height;
428
- if (!Number.isSafeInteger(total) || total > 13_238_272)
429
- invalid('Province dimensions exceed the 13,238,272-pixel map limit');
430
- if (!Number.isSafeInteger(seed))
431
- invalid('The province seed must be a safe integer');
432
- if (!(kind instanceof Uint8Array) ||
433
- !(elevation instanceof Float32Array) ||
434
- !(moisture instanceof Float32Array))
435
- invalid('Province fields must use the specified typed arrays');
436
- if (kind.length !== total || elevation.length !== total || moisture.length !== total)
437
- invalid('Province fields must match the map dimensions');
438
- if (![input.landProvincePixels, input.seaProvincePixels].every((v) => Number.isFinite(v) && v >= 20))
439
- invalid('Province target areas must be finite and at least 20 pixels');
440
- for (let p = 0; p < total; p++) {
441
- if (kind[p] > 2 || !Number.isFinite(elevation[p]) || !Number.isFinite(moisture[p]))
442
- invalid('Province fields contain an unsupported kind or non-finite terrain value');
443
- }
444
- // A singleton kind component necessarily needs its own province. Count that
445
- // inexpensive lower bound before building sites for a hostile fragmented mask.
478
+ export function assertProvinceMaskFeasible(input) {
479
+ const { width, kind } = input;
480
+ const total = width * input.height;
446
481
  let isolated = 0;
447
482
  for (let p = 0; p < total; p++) {
448
483
  const x = p % width;
@@ -456,7 +491,18 @@ export function generateWorldProvinces(input) {
456
491
  }
457
492
  if (isolated > 65_000)
458
493
  throw new ServiceError('MAP_WORLD_TOO_MANY_PROVINCES', 'The kind mask requires more than 65,000 isolated provinces', { isolated });
459
- const parts = connectedParts(input, nearestPartition(input));
494
+ }
495
+ /**
496
+ * Turn one label per pixel into provinces: split disconnected and over-wide parts, absorb slivers,
497
+ * keep every province at least 20 pixels where the mask allows, repair four-province corners and
498
+ * number the provinces by their first pixel. Both partitions finish through this function, so the
499
+ * engine rules (connected provinces, the bounding box limit, no X crossing, the 65,000 cap) hold
500
+ * whichever one made the labels.
501
+ */
502
+ export function assembleWorldProvinces(input, labels) {
503
+ const { width, height, kind } = input;
504
+ const total = width * height;
505
+ const parts = connectedParts(input, labels);
460
506
  absorbSlivers(input, parts);
461
507
  rebalanceSlivers(input, parts);
462
508
  repairCrossings(input, parts);
@@ -522,3 +568,32 @@ export function generateWorldProvinces(input) {
522
568
  }
523
569
  return result;
524
570
  }
571
+ /**
572
+ * Deterministic province geometry with periodic adjacency, but separate province IDs at the seam.
573
+ * Native province bounding boxes use bitmap coordinates, not circular spans. Work is proportional to pixels
574
+ * with bounded local candidate searches; no retained per-pixel objects or global site scans.
575
+ */
576
+ export function generateWorldProvinces(input) {
577
+ const { width, height, kind, elevation, moisture, seed } = input;
578
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16)
579
+ invalid('Province dimensions must be integers of at least 16 pixels');
580
+ const total = width * height;
581
+ if (!Number.isSafeInteger(total) || total > 13_238_272)
582
+ invalid('Province dimensions exceed the 13,238,272-pixel map limit');
583
+ if (!Number.isSafeInteger(seed))
584
+ invalid('The province seed must be a safe integer');
585
+ if (!(kind instanceof Uint8Array) ||
586
+ !(elevation instanceof Float32Array) ||
587
+ !(moisture instanceof Float32Array))
588
+ invalid('Province fields must use the specified typed arrays');
589
+ if (kind.length !== total || elevation.length !== total || moisture.length !== total)
590
+ invalid('Province fields must match the map dimensions');
591
+ if (![input.landProvincePixels, input.seaProvincePixels].every((v) => Number.isFinite(v) && v >= 20))
592
+ invalid('Province target areas must be finite and at least 20 pixels');
593
+ for (let p = 0; p < total; p++) {
594
+ if (kind[p] > 2 || !Number.isFinite(elevation[p]) || !Number.isFinite(moisture[p]))
595
+ invalid('Province fields contain an unsupported kind or non-finite terrain value');
596
+ }
597
+ assertProvinceMaskFeasible(input);
598
+ return assembleWorldProvinces(input, nearestPartition(input));
599
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Workspace-owned countries in a generated world (ADR 0069): realm points and polygons resolved to
3
+ * provinces, and the rule for land that no realm reached.
4
+ */
5
+ import { ServiceError } from '../core/result.js';
6
+ function inside(x, y, polygon) {
7
+ let result = false;
8
+ for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
9
+ const a = polygon[index], b = polygon[previous];
10
+ if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x)
11
+ result = !result;
12
+ }
13
+ return result;
14
+ }
15
+ /** Land provinces with more than half their pixels inside any of the polygons. */
16
+ function coveredProvinces(polygons, raster) {
17
+ const counts = new Map();
18
+ const pixels = new Set();
19
+ for (const polygon of polygons) {
20
+ const xs = polygon.map(({ x }) => x), ys = polygon.map(({ y }) => y);
21
+ const left = Math.max(0, Math.floor(Math.min(...xs)));
22
+ const right = Math.min(raster.width - 1, Math.ceil(Math.max(...xs)));
23
+ const top = Math.max(0, Math.floor(Math.min(...ys)));
24
+ const bottom = Math.min(raster.height - 1, Math.ceil(Math.max(...ys)));
25
+ for (let y = top; y <= bottom; y += 1)
26
+ for (let x = left; x <= right; x += 1) {
27
+ const pixel = y * raster.width + x;
28
+ if (pixels.has(pixel) || !inside(x + 0.5, y + 0.5, polygon))
29
+ continue;
30
+ pixels.add(pixel);
31
+ const province = raster.pixelProvince[pixel];
32
+ counts.set(province, (counts.get(province) ?? 0) + 1);
33
+ }
34
+ }
35
+ const covered = new Set();
36
+ for (const [province, count] of [...counts].sort((left, right) => left[0] - right[0]))
37
+ if (raster.isLand(province) && count * 2 > raster.provinceSize[province])
38
+ covered.add(province);
39
+ return covered;
40
+ }
41
+ function pointProvince(tag, role, point, raster) {
42
+ const x = Math.floor(point.x), y = Math.floor(point.y);
43
+ if (x < 0 || y < 0 || x >= raster.width || y >= raster.height)
44
+ throw new ServiceError('MAP_WORLD_REALM_POINT_OUTSIDE', `The ${role} of ${tag} lies outside the ${raster.width}x${raster.height} world`, { tag, role, point });
45
+ const province = raster.pixelProvince[y * raster.width + x];
46
+ if (!raster.isLand(province))
47
+ throw new ServiceError('MAP_WORLD_REALM_POINT_WATER', `The ${role} of ${tag} lies in water`, {
48
+ tag,
49
+ role,
50
+ point,
51
+ });
52
+ return province;
53
+ }
54
+ export function resolveRealmRules(realms, raster) {
55
+ const unowned = realms.unowned === undefined ? new Set() : coveredProvinces(realms.unowned, raster);
56
+ const excluded = realms.list.map((realm) => realm.exclude === undefined ? new Set() : coveredProvinces(realm.exclude, raster));
57
+ const forced = new Map();
58
+ const claimedBy = (province) => realms.list[forced.get(province)].tag;
59
+ const conflict = (tag, role, other, details) => new ServiceError('MAP_WORLD_REALM_POINT_CONFLICT', `The ${role} of ${tag} lies in land ${other}`, { tag, role, ...details });
60
+ const capitals = [];
61
+ // Points first, so a claim that covers another realm's point names that point.
62
+ for (const [index, realm] of realms.list.entries()) {
63
+ for (const [role, point] of [
64
+ ['capital', realm.capital],
65
+ ...(realm.seeds ?? []).map((seed) => ['seed', seed]),
66
+ ]) {
67
+ const province = pointProvince(realm.tag, role, point, raster);
68
+ if (unowned.has(province))
69
+ throw conflict(realm.tag, role, 'marked unowned', { point, province });
70
+ if (excluded[index].has(province))
71
+ throw conflict(realm.tag, role, 'its own exclusion covers', { point, province });
72
+ const holder = forced.get(province);
73
+ if (holder !== undefined && holder !== index)
74
+ throw conflict(realm.tag, role, `that a point of ${claimedBy(province)} names`, {
75
+ point,
76
+ province,
77
+ otherTag: claimedBy(province),
78
+ });
79
+ forced.set(province, index);
80
+ if (role === 'capital')
81
+ capitals.push(province);
82
+ }
83
+ }
84
+ for (const [index, realm] of realms.list.entries()) {
85
+ if (realm.claim === undefined)
86
+ continue;
87
+ for (const province of coveredProvinces(realm.claim, raster)) {
88
+ const holder = forced.get(province);
89
+ if (unowned.has(province) || excluded[index].has(province))
90
+ throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that is ${unowned.has(province) ? 'marked unowned' : 'in its own exclusion'}`, { tag: realm.tag, province });
91
+ if (holder !== undefined && holder !== index)
92
+ throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that ${claimedBy(province)} claims or names`, { tag: realm.tag, otherTag: claimedBy(province), province });
93
+ forced.set(province, index);
94
+ }
95
+ }
96
+ return { capitals, forced, excluded, unowned };
97
+ }
98
+ /**
99
+ * Land growth did not reach: a landmass that a realm already holds goes to the nearest realm that
100
+ * may own it; a landmass without any realm follows the island rule.
101
+ */
102
+ export function assignUnreachedRealmLand(input) {
103
+ const held = new Set();
104
+ for (const id of input.owner.keys())
105
+ held.add(input.part(id));
106
+ const distance = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
107
+ const nearest = (point, allowed) => {
108
+ let best = -1;
109
+ let bestDistance = Number.POSITIVE_INFINITY;
110
+ for (const [realm, capital] of input.capitals.entries()) {
111
+ if (!allowed(realm))
112
+ continue;
113
+ const next = distance(point, input.center(capital));
114
+ if (next < bestDistance) {
115
+ best = realm;
116
+ bestDistance = next;
117
+ }
118
+ }
119
+ return best;
120
+ };
121
+ const pending = input.landIds.filter((id) => !input.owner.has(id) && !input.unowned.has(id));
122
+ const partOwner = new Map();
123
+ if (input.islands === 'one-owner') {
124
+ const members = new Map();
125
+ for (const id of pending)
126
+ if (!held.has(input.part(id))) {
127
+ const list = members.get(input.part(id)) ?? [];
128
+ list.push(id);
129
+ members.set(input.part(id), list);
130
+ }
131
+ for (const [part, ids] of members) {
132
+ const mean = ids.reduce((sum, id) => ({ x: sum.x + input.center(id).x, y: sum.y + input.center(id).y }), { x: 0, y: 0 });
133
+ partOwner.set(part, nearest({ x: mean.x / ids.length, y: mean.y / ids.length }, () => true));
134
+ }
135
+ }
136
+ for (const id of pending) {
137
+ const allowed = (realm) => !input.excluded[realm].has(id);
138
+ const island = !held.has(input.part(id));
139
+ if (island && input.islands === 'unowned') {
140
+ input.unowned.add(id);
141
+ continue;
142
+ }
143
+ const preferred = island ? partOwner.get(input.part(id)) : undefined;
144
+ const realm = preferred !== undefined && preferred >= 0 && allowed(preferred)
145
+ ? preferred
146
+ : nearest(input.center(id), allowed);
147
+ if (realm < 0)
148
+ input.unowned.add(id);
149
+ else
150
+ input.owner.set(id, realm);
151
+ }
152
+ }