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.
Files changed (110) hide show
  1. package/CHANGELOG.md +25 -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/operation-context.js +17 -1
  5. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  6. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  7. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  8. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  9. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  10. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  11. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  12. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  13. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  14. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  15. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  16. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  17. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  18. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  19. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  20. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  21. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  22. package/dist/hoi4_agent_tools/map/model.js +15 -2
  23. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  24. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  25. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  26. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  27. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  28. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  29. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  30. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  31. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  32. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  33. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  34. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  35. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  36. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  37. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  38. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  39. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  40. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  41. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  42. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  43. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  44. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  45. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  46. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  47. package/dist/hoi4_agent_tools/map/render.js +36 -0
  48. package/dist/hoi4_agent_tools/map/service.js +191 -16
  49. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  50. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  51. package/dist/hoi4_agent_tools/map/water.js +227 -0
  52. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  53. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  54. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  55. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  56. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  57. package/dist/hoi4_agent_tools/map/world.js +462 -124
  58. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  59. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  60. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  61. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  62. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  63. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  64. package/dist/hoi4_agent_tools/version.js +1 -1
  65. package/docs/configuration.md +17 -14
  66. package/docs/map.md +258 -15
  67. package/docs/tools.md +4 -4
  68. package/docs/troubleshooting.md +11 -11
  69. package/npm-shrinkwrap.json +109 -91
  70. package/package.json +3 -3
  71. package/schemas/configuration.schema.json +65 -58
  72. package/schemas/continuous-focus-palette.schema.json +1 -1
  73. package/schemas/custom-weighted-pool.schema.json +1 -1
  74. package/schemas/decision-inspect-input.schema.json +1 -1
  75. package/schemas/error-log-input.schema.json +1 -1
  76. package/schemas/event-feature-manifest.schema.json +1 -1
  77. package/schemas/focus-plan.schema.json +1 -1
  78. package/schemas/focus-planning-sidecar.schema.json +1 -1
  79. package/schemas/geography-model.schema.json +1148 -0
  80. package/schemas/gui-animation-source.schema.json +1 -1
  81. package/schemas/gui-helper.schema.json +5 -11
  82. package/schemas/gui-scenario.schema.json +1 -1
  83. package/schemas/helper-expansion-request.schema.json +1 -1
  84. package/schemas/helper-expansion-summary.schema.json +1 -1
  85. package/schemas/impact-inspect-input.schema.json +1 -1
  86. package/schemas/map-operation.schema.json +2156 -259
  87. package/schemas/mechanic-test-input.schema.json +1 -1
  88. package/schemas/mod-index-input.schema.json +1 -1
  89. package/schemas/operation-result.schema.json +1 -1
  90. package/schemas/package-check-input.schema.json +1 -1
  91. package/schemas/package-manifest.schema.json +1 -1
  92. package/schemas/probability-analysis-result.schema.json +25 -7
  93. package/schemas/probability-compare-input.schema.json +1 -1
  94. package/schemas/probability-evaluate-input.schema.json +1 -1
  95. package/schemas/probability-inspect-input.schema.json +1 -1
  96. package/schemas/probability-render-input.schema.json +1 -1
  97. package/schemas/probability-scenario-set.schema.json +1 -1
  98. package/schemas/probability-sequence-input.schema.json +1 -1
  99. package/schemas/probability-simulate-input.schema.json +1 -1
  100. package/schemas/probability-sweep-input.schema.json +1 -1
  101. package/schemas/reference-context-input.schema.json +1 -1
  102. package/schemas/reference-read-input.schema.json +1 -1
  103. package/schemas/reference-search-input.schema.json +1 -1
  104. package/schemas/reference-sheets.schema.json +267 -0
  105. package/schemas/scenario-suite.schema.json +1 -1
  106. package/schemas/scenario-test-input.schema.json +1 -1
  107. package/schemas/script-validate-input.schema.json +1 -1
  108. package/schemas/shared-scenario.schema.json +1 -1
  109. package/schemas/source-lookup-input.schema.json +1 -1
  110. package/server.json +2 -2
@@ -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
+ }