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,727 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ import { assembleWorldProvinces, assertProvinceMaskFeasible } from './world-provinces.js';
3
+ import { assertPartitionFields, PARTITION_KIND, PARTITION_TERRAIN } from './partition-fields.js';
4
+ import { PARTITION_PROVINCE_ERROR } from './partition-tuning.js';
5
+ import { hash32, placeSeeds, STEP_UNIT, unit32, weightedWatershed } from './partition-watershed.js';
6
+ /**
7
+ * Feature-aware provinces (ADR 0058). The land is cut by a marker-controlled watershed whose
8
+ * cost rises on rivers, ridges, steep slopes, walls and shores, so borders prefer natural lines;
9
+ * seeds are drawn at a density that follows terrain, fertility, coast and river proximity and the
10
+ * density of the geography region, so provinces are small in rich valleys and on coasts and large
11
+ * in wilderness. Sea provinces are small near coasts and large in the open sea, and a narrow
12
+ * channel keeps two sea provinces across it. The result goes through the same finishing passes as
13
+ * the legacy partition, so every engine rule it keeps (connected provinces, the bounding box
14
+ * limit, no four-province corner, 65,000 provinces) holds here too.
15
+ */
16
+ /** A piece cut off by a river smaller than this rejoins the province it was cut from. */
17
+ const MIN_PIECE_PIXELS = 8;
18
+ const LAND = PARTITION_KIND.land;
19
+ const SEA = PARTITION_KIND.sea;
20
+ const LAKE = PARTITION_KIND.lake;
21
+ const EMPTY_STATISTICS = {
22
+ count: 0,
23
+ min: 0,
24
+ p10: 0,
25
+ median: 0,
26
+ p90: 0,
27
+ max: 0,
28
+ mean: 0,
29
+ };
30
+ function statisticsOf(sizes) {
31
+ if (sizes.length === 0)
32
+ return EMPTY_STATISTICS;
33
+ const sorted = Float64Array.from(sizes).sort();
34
+ const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
35
+ let sum = 0;
36
+ for (const value of sorted)
37
+ sum += value;
38
+ return {
39
+ count: sorted.length,
40
+ min: sorted[0],
41
+ p10: at(0.1),
42
+ median: at(0.5),
43
+ p90: at(0.9),
44
+ max: sorted[sorted.length - 1],
45
+ mean: Math.round((sum / sorted.length) * 100) / 100,
46
+ };
47
+ }
48
+ /** Size distribution of the provinces of each kind. */
49
+ export function provinceStatistics(provinces, expectedProvinces = 0) {
50
+ const by = [[], [], []];
51
+ for (let id = 0; id < provinces.kind.length; id++)
52
+ by[provinces.kind[id]].push(provinces.size[id]);
53
+ return {
54
+ land: statisticsOf(by[LAND]),
55
+ sea: statisticsOf(by[SEA]),
56
+ lake: statisticsOf(by[LAKE]),
57
+ expectedProvinces,
58
+ };
59
+ }
60
+ /** Land masses (eight-connected, wrapping east-west) numbered from 1 in raster order. */
61
+ function labelLandMasses(kind, width, height, queue) {
62
+ const total = width * height;
63
+ const mass = new Uint16Array(total);
64
+ let next = 0;
65
+ for (let start = 0; start < total; start++) {
66
+ if (kind[start] !== LAND || mass[start] !== 0)
67
+ continue;
68
+ next = Math.min(65_535, next + 1);
69
+ let head = 0;
70
+ let tail = 0;
71
+ queue[tail++] = start;
72
+ mass[start] = next;
73
+ while (head < tail) {
74
+ const p = queue[head++];
75
+ const x = p % width;
76
+ const y = (p - x) / width;
77
+ for (let dy = -1; dy <= 1; dy++) {
78
+ const ny = y + dy;
79
+ if (ny < 0 || ny >= height)
80
+ continue;
81
+ for (let dx = -1; dx <= 1; dx++) {
82
+ if (dx === 0 && dy === 0)
83
+ continue;
84
+ const nx = x + dx < 0 ? width - 1 : x + dx >= width ? 0 : x + dx;
85
+ const q = ny * width + nx;
86
+ if (kind[q] !== LAND || mass[q] !== 0)
87
+ continue;
88
+ mass[q] = next;
89
+ queue[tail++] = q;
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return mass;
95
+ }
96
+ /** Breadth-first distance (four neighbours) from the land into the water, carrying the land mass. */
97
+ function waterShore(kind, landMass, width, height, queue) {
98
+ const total = width * height;
99
+ const distance = new Uint8Array(total).fill(255);
100
+ const mass = new Uint16Array(total);
101
+ let head = 0;
102
+ let tail = 0;
103
+ for (let p = 0; p < total; p++)
104
+ if (kind[p] === LAND) {
105
+ distance[p] = 0;
106
+ mass[p] = landMass[p];
107
+ queue[tail++] = p;
108
+ }
109
+ while (head < tail) {
110
+ const p = queue[head++];
111
+ const d = distance[p];
112
+ if (d >= 254)
113
+ continue;
114
+ const x = p % width;
115
+ const neighbours = [
116
+ x > 0 ? p - 1 : p + width - 1,
117
+ x + 1 < width ? p + 1 : p - width + 1,
118
+ p >= width ? p - width : -1,
119
+ p + width < total ? p + width : -1,
120
+ ];
121
+ for (const q of neighbours) {
122
+ if (q < 0 || distance[q] !== 255 || kind[q] === LAND)
123
+ continue;
124
+ distance[q] = d + 1;
125
+ mass[q] = mass[p];
126
+ queue[tail++] = q;
127
+ }
128
+ }
129
+ return { distance, mass };
130
+ }
131
+ /** Breadth-first distance (four neighbours) from the water into the land. */
132
+ function landCoastDistance(kind, width, height, queue) {
133
+ const total = width * height;
134
+ const distance = new Uint8Array(total).fill(255);
135
+ let head = 0;
136
+ let tail = 0;
137
+ for (let p = 0; p < total; p++)
138
+ if (kind[p] !== LAND) {
139
+ distance[p] = 0;
140
+ queue[tail++] = p;
141
+ }
142
+ while (head < tail) {
143
+ const p = queue[head++];
144
+ const d = distance[p];
145
+ if (d >= 254)
146
+ continue;
147
+ const x = p % width;
148
+ const neighbours = [
149
+ x > 0 ? p - 1 : p + width - 1,
150
+ x + 1 < width ? p + 1 : p - width + 1,
151
+ p >= width ? p - width : -1,
152
+ p + width < total ? p + width : -1,
153
+ ];
154
+ for (const q of neighbours) {
155
+ if (q < 0 || distance[q] !== 255)
156
+ continue;
157
+ distance[q] = d + 1;
158
+ queue[tail++] = q;
159
+ }
160
+ }
161
+ void height;
162
+ return distance;
163
+ }
164
+ // ---------------------------------------------------------------------------------------------
165
+ // Cost fields
166
+ /** River, ridge, slope, shore and wall strength at every pixel (the plan's barrier field B). */
167
+ export function buildBarrierField(fields, tuning, seaShore) {
168
+ const { width, height, kind } = fields;
169
+ const total = width * height;
170
+ const barrier = new Uint8Array(total);
171
+ const shelf = Math.round(Math.sqrt(tuning.seaArea.open) * 1.1);
172
+ const shelfLine = tuning.seaArea.open > tuning.seaArea.coastal * 1.5 && seaShore !== undefined;
173
+ for (let y = 0; y < height; y++) {
174
+ const row = y * width;
175
+ for (let x = 0; x < width; x++) {
176
+ const p = row + x;
177
+ const k = kind[p];
178
+ if (k === SEA) {
179
+ // A faint contour along the shelf edge: sea borders follow the coast.
180
+ if (shelfLine && Math.abs(seaShore[p] - shelf) <= 1)
181
+ barrier[p] = 110;
182
+ continue;
183
+ }
184
+ if (k === LAKE)
185
+ continue;
186
+ const river = fields.riverDistance[p];
187
+ let value = river === 0 ? 255 : river === 1 ? 170 : river === 2 ? 70 : 0;
188
+ const slope = fields.slope[p];
189
+ if (slope > 16) {
190
+ const s = slope >= 64 ? 255 : Math.round(((slope - 16) * 255) / 48);
191
+ if (s > value)
192
+ value = s;
193
+ }
194
+ const ridge = Math.round(fields.ridge[p] * 0.65);
195
+ if (ridge > value)
196
+ value = ridge;
197
+ const wall = fields.barrier[p];
198
+ if (wall > value)
199
+ value = wall;
200
+ if (value < 90) {
201
+ const left = x === 0 ? width - 1 : x - 1;
202
+ const right = x === width - 1 ? 0 : x + 1;
203
+ if (kind[row + left] === LAKE ||
204
+ kind[row + right] === LAKE ||
205
+ (y > 0 && kind[p - width] === LAKE) ||
206
+ (y < height - 1 && kind[p + width] === LAKE))
207
+ value = 90;
208
+ }
209
+ barrier[p] = value;
210
+ }
211
+ }
212
+ return barrier;
213
+ }
214
+ /** Periodic value noise on a coarse lattice, interpolated per pixel; `[-1, 1]`. */
215
+ function coarseNoise(seed, width, height, cell, write, wanted) {
216
+ const columns = Math.max(2, Math.round(width / cell));
217
+ const gx = width / columns;
218
+ const gy = Math.max(2, cell);
219
+ const rowA = new Float32Array(columns + 1);
220
+ const rowB = new Float32Array(columns + 1);
221
+ let lastRow = -1;
222
+ for (let y = 0; y < height; y++) {
223
+ const fy = y / gy;
224
+ const iy = Math.floor(fy);
225
+ if (iy !== lastRow) {
226
+ for (let c = 0; c <= columns; c++) {
227
+ rowA[c] = unit32(hash32(seed, (c % columns) + 1, iy));
228
+ rowB[c] = unit32(hash32(seed, (c % columns) + 1, iy + 1));
229
+ }
230
+ lastRow = iy;
231
+ }
232
+ const ty = fy - iy;
233
+ const sy = ty * ty * (3 - 2 * ty);
234
+ const row = y * width;
235
+ for (let x = 0; x < width; x++) {
236
+ if (!wanted(row + x))
237
+ continue;
238
+ const fx = x / gx;
239
+ const ix = Math.floor(fx);
240
+ const tx = fx - ix;
241
+ const sx = tx * tx * (3 - 2 * tx);
242
+ const a = rowA[ix] + (rowA[ix + 1] - rowA[ix]) * sx;
243
+ const b = rowB[ix] + (rowB[ix + 1] - rowB[ix]) * sx;
244
+ write(row + x, (a + (b - a) * sy) * 2 - 1);
245
+ }
246
+ }
247
+ }
248
+ /** Cost multiplier of entering each pixel, 256 for a plain step. */
249
+ export function buildStepField(fields, barrier, tuning, seed) {
250
+ const { width, height, kind } = fields;
251
+ const step = new Uint16Array(width * height);
252
+ const beta = tuning.barrierStrength;
253
+ for (let p = 0; p < step.length; p++) {
254
+ const value = STEP_UNIT * (1 + (beta * barrier[p]) / 255);
255
+ step[p] = value > 65_000 ? 65_000 : Math.round(value);
256
+ }
257
+ // Jitter keeps borders between featureless cells from being straight bisectors.
258
+ const landCell = Math.max(4, Math.round(0.6 * Math.sqrt(tuning.landArea.plains)));
259
+ const seaCell = Math.max(4, Math.round(0.5 * Math.sqrt(tuning.seaArea.open)));
260
+ const jitter = (amplitude) => (pixel, value) => {
261
+ const next = Math.round(step[pixel] * (1 + amplitude * value));
262
+ step[pixel] = next < 32 ? 32 : next > 65_000 ? 65_000 : next;
263
+ };
264
+ coarseNoise(seed + 101, width, height, landCell, jitter(0.12), (p) => kind[p] === LAND);
265
+ coarseNoise(seed + 211, width, height, seaCell, jitter(0.3), (p) => kind[p] !== LAND);
266
+ return step;
267
+ }
268
+ // ---------------------------------------------------------------------------------------------
269
+ // Planned areas
270
+ function smoothstep(t) {
271
+ const c = t < 0 ? 0 : t > 1 ? 1 : t;
272
+ return c * c * (3 - 2 * c);
273
+ }
274
+ function planAreas(fields, tuning, coastDistance, seaDistance) {
275
+ const { width, height, kind } = fields;
276
+ const t = tuning;
277
+ const classSqrt = new Float64Array(256).fill(Math.sqrt(t.landArea.plains));
278
+ const set = (value, ...classes) => {
279
+ for (const c of classes)
280
+ classSqrt[c] = Math.sqrt(value);
281
+ };
282
+ set(t.landArea.fertile, PARTITION_TERRAIN.farmland);
283
+ set(t.landArea.fertile * 0.7, PARTITION_TERRAIN.urban);
284
+ set(t.landArea.hills, PARTITION_TERRAIN.hills);
285
+ set(t.landArea.mountain, PARTITION_TERRAIN.mountain);
286
+ set(t.landArea.waste, PARTITION_TERRAIN.desert, PARTITION_TERRAIN.snow);
287
+ const sqrtFertile = Math.sqrt(t.landArea.fertile);
288
+ const coastReach = 2 * sqrtFertile;
289
+ const riverReach = 1.2 * sqrtFertile;
290
+ const regionScale = new Float32Array(65_536).fill(1);
291
+ for (const region of fields.regions ?? [])
292
+ if (region.provinceAreaPx !== undefined)
293
+ regionScale[region.id] = region.provinceAreaPx / t.landArea.plains;
294
+ const landRaw = (pixel) => {
295
+ let s = classSqrt[fields.terrainClass[pixel]];
296
+ const f = fields.fertility[pixel] / 255;
297
+ if (f > 0.4 && s > sqrtFertile)
298
+ s += (sqrtFertile - s) * 0.7 * smoothstep((f - 0.4) / 0.5);
299
+ let area = s * s;
300
+ const coast = coastDistance[pixel];
301
+ if (coast < coastReach)
302
+ area *= 1 - 0.25 * (1 - coast / coastReach);
303
+ const river = fields.riverDistance[pixel];
304
+ if (river < riverReach)
305
+ area *= 1 - 0.2 * (1 - river / riverReach);
306
+ const region = fields.regionId[pixel];
307
+ if (region !== 0)
308
+ area *= regionScale[region];
309
+ return area;
310
+ };
311
+ const near = Math.sqrt(t.seaArea.coastal) * 0.5;
312
+ const far = near + 2.2 * Math.sqrt(t.seaArea.open);
313
+ const sqrtCoastal = Math.sqrt(t.seaArea.coastal);
314
+ const sqrtOpen = Math.sqrt(t.seaArea.open);
315
+ const seaRaw = (pixel) => {
316
+ const w = smoothstep((seaDistance[pixel] - near) / (far - near));
317
+ const s = sqrtCoastal + (sqrtOpen - sqrtCoastal) * w;
318
+ return s * s;
319
+ };
320
+ // Sample the plan on a coarse grid: the mean planned areas, then the smallest areas and the
321
+ // expected province count once the plan is scaled to the request's base areas.
322
+ let landSamples = 0;
323
+ let seaSamples = 0;
324
+ let landInverse = 0;
325
+ let seaInverse = 0;
326
+ for (let y = 2; y < height; y += 4)
327
+ for (let x = 2; x < width; x += 4) {
328
+ const p = y * width + x;
329
+ if (kind[p] === LAND) {
330
+ landSamples++;
331
+ landInverse += 1 / Math.max(1, landRaw(p));
332
+ }
333
+ else if (kind[p] === SEA) {
334
+ seaSamples++;
335
+ seaInverse += 1 / Math.max(1, seaRaw(p));
336
+ }
337
+ }
338
+ // The mean count is samples / area, so a scale of (sum of 1/raw) * base / samples gives the base.
339
+ const landScale = t.normalizeLand && landSamples > 0 ? (landInverse * t.landBase) / landSamples : 1;
340
+ const seaScale = t.normalizeSea && seaSamples > 0 ? (seaInverse * t.seaBase) / seaSamples : 1;
341
+ const clampArea = (area) => area < t.minProvincePixels
342
+ ? t.minProvincePixels
343
+ : area > t.maxProvincePixels
344
+ ? t.maxProvincePixels
345
+ : area;
346
+ const land = (pixel) => clampArea(landRaw(pixel) * landScale);
347
+ const sea = (pixel) => kind[pixel] === LAKE ? t.lakeArea : clampArea(seaRaw(pixel) * seaScale);
348
+ let minLand = Infinity;
349
+ let minSea = Infinity;
350
+ let expected = 0;
351
+ for (let y = 2; y < height; y += 4)
352
+ for (let x = 2; x < width; x += 4) {
353
+ const p = y * width + x;
354
+ if (kind[p] === LAND) {
355
+ const a = land(p);
356
+ if (a < minLand)
357
+ minLand = a;
358
+ expected += 16 / a;
359
+ }
360
+ else {
361
+ const a = sea(p);
362
+ if (kind[p] === SEA && a < minSea)
363
+ minSea = a;
364
+ expected += 16 / a;
365
+ }
366
+ }
367
+ return {
368
+ land,
369
+ sea,
370
+ minLand: Number.isFinite(minLand) ? minLand : t.minProvincePixels,
371
+ minSea: Number.isFinite(minSea) ? minSea : t.minProvincePixels,
372
+ expected,
373
+ };
374
+ }
375
+ // ---------------------------------------------------------------------------------------------
376
+ // Seeds for components the dart throwing missed
377
+ /** One seed in every connected piece of one kind (eight neighbours, wrapping) that has none. */
378
+ function seedMissedComponents(kind, width, height, seeded, queue) {
379
+ const total = width * height;
380
+ const visited = new Uint8Array(total);
381
+ const extra = [];
382
+ for (let start = 0; start < total; start++) {
383
+ if (visited[start])
384
+ continue;
385
+ const here = kind[start];
386
+ let head = 0;
387
+ let tail = 0;
388
+ let hasSeed = false;
389
+ queue[tail++] = start;
390
+ visited[start] = 1;
391
+ while (head < tail) {
392
+ const p = queue[head++];
393
+ if (seeded[p])
394
+ hasSeed = true;
395
+ const x = p % width;
396
+ const y = (p - x) / width;
397
+ for (let dy = -1; dy <= 1; dy++) {
398
+ const ny = y + dy;
399
+ if (ny < 0 || ny >= height)
400
+ continue;
401
+ for (let dx = -1; dx <= 1; dx++) {
402
+ if (dx === 0 && dy === 0)
403
+ continue;
404
+ const nx = x + dx < 0 ? width - 1 : x + dx >= width ? 0 : x + dx;
405
+ const q = ny * width + nx;
406
+ if (visited[q] || kind[q] !== here)
407
+ continue;
408
+ visited[q] = 1;
409
+ queue[tail++] = q;
410
+ }
411
+ }
412
+ }
413
+ if (!hasSeed)
414
+ extra.push(start);
415
+ }
416
+ return extra;
417
+ }
418
+ // ---------------------------------------------------------------------------------------------
419
+ // Splitting along rivers and narrow straits
420
+ /**
421
+ * A province that still straddles a river or wall is cut along it: its pieces (four-connected
422
+ * once the line is removed) each become a province, unless a piece is smaller than the minimum,
423
+ * which stays with its larger neighbour. Returns the number of provinces added.
424
+ */
425
+ function splitAcrossLines(fields, labels, minPixels, queue) {
426
+ const { width, height, kind } = fields;
427
+ const total = width * height;
428
+ const hard = (p) => kind[p] === LAND && (fields.riverDistance[p] === 0 || fields.barrier[p] >= 200);
429
+ const piece = new Int32Array(total).fill(-1);
430
+ const sizes = [];
431
+ for (let start = 0; start < total; start++) {
432
+ if (piece[start] !== -1 || kind[start] !== LAND || hard(start))
433
+ continue;
434
+ const id = sizes.length;
435
+ const label = labels[start];
436
+ let head = 0;
437
+ let tail = 0;
438
+ queue[tail++] = start;
439
+ piece[start] = id;
440
+ while (head < tail) {
441
+ const p = queue[head++];
442
+ const x = p % width;
443
+ const neighbours = [
444
+ x > 0 ? p - 1 : p + width - 1,
445
+ x + 1 < width ? p + 1 : p - width + 1,
446
+ p >= width ? p - width : -1,
447
+ p + width < total ? p + width : -1,
448
+ ];
449
+ for (const q of neighbours)
450
+ if (q >= 0 && piece[q] === -1 && labels[q] === label && kind[q] === LAND && !hard(q)) {
451
+ piece[q] = id;
452
+ queue[tail++] = q;
453
+ }
454
+ }
455
+ sizes.push(tail);
456
+ }
457
+ // Line pixels join the smallest-numbered neighbouring piece of the same province.
458
+ for (let pass = 0; pass < 8; pass++) {
459
+ let changed = false;
460
+ for (let p = 0; p < total; p++) {
461
+ if (piece[p] !== -1 || kind[p] !== LAND)
462
+ continue;
463
+ const x = p % width;
464
+ let best = -1;
465
+ for (const q of [
466
+ x > 0 ? p - 1 : p + width - 1,
467
+ x + 1 < width ? p + 1 : p - width + 1,
468
+ p >= width ? p - width : -1,
469
+ p + width < total ? p + width : -1,
470
+ ])
471
+ if (q >= 0 &&
472
+ piece[q] >= 0 &&
473
+ labels[q] === labels[p] &&
474
+ (best === -1 || piece[q] < best))
475
+ best = piece[q];
476
+ if (best >= 0) {
477
+ piece[p] = best;
478
+ sizes[best]++;
479
+ changed = true;
480
+ }
481
+ }
482
+ if (!changed)
483
+ break;
484
+ }
485
+ for (let p = 0; p < total; p++)
486
+ if (piece[p] === -1 && kind[p] === LAND) {
487
+ piece[p] = sizes.length;
488
+ sizes.push(1);
489
+ }
490
+ // A small piece next to another piece of its province joins the largest such neighbour.
491
+ const parent = Int32Array.from({ length: sizes.length }, (_, i) => i);
492
+ const find = (i) => {
493
+ while (parent[i] !== i) {
494
+ parent[i] = parent[parent[i]];
495
+ i = parent[i];
496
+ }
497
+ return i;
498
+ };
499
+ const best = new Int32Array(sizes.length).fill(-1);
500
+ for (let p = 0; p < total; p++) {
501
+ if (kind[p] !== LAND)
502
+ continue;
503
+ const x = p % width;
504
+ for (const q of [x + 1 < width ? p + 1 : -1, p + width < total ? p + width : -1]) {
505
+ if (q < 0 || kind[q] !== LAND || labels[q] !== labels[p] || piece[q] === piece[p])
506
+ continue;
507
+ const a = piece[p];
508
+ const b = piece[q];
509
+ if (sizes[a] < minPixels && (best[a] === -1 || sizes[b] > sizes[best[a]]))
510
+ best[a] = b;
511
+ if (sizes[b] < minPixels && (best[b] === -1 || sizes[a] > sizes[best[b]]))
512
+ best[b] = a;
513
+ }
514
+ }
515
+ const order = Array.from({ length: sizes.length }, (_, i) => i)
516
+ .filter((i) => sizes[i] < minPixels && best[i] !== -1)
517
+ .sort((a, b) => sizes[a] - sizes[b] || a - b);
518
+ for (const i of order) {
519
+ const target = find(best[i]);
520
+ const source = find(i);
521
+ if (target === source)
522
+ continue;
523
+ parent[source] = target;
524
+ sizes[target] += sizes[source];
525
+ sizes[source] = 0;
526
+ }
527
+ let maxLabel = 0;
528
+ for (let p = 0; p < total; p++)
529
+ if (labels[p] > maxLabel)
530
+ maxLabel = labels[p];
531
+ const distinct = new Map();
532
+ let added = 0;
533
+ const original = new Set();
534
+ for (let p = 0; p < total; p++) {
535
+ if (kind[p] !== LAND)
536
+ continue;
537
+ const root = find(piece[p]);
538
+ let id = distinct.get(root);
539
+ if (id === undefined) {
540
+ id = maxLabel + 1 + distinct.size;
541
+ distinct.set(root, id);
542
+ added++;
543
+ }
544
+ original.add(labels[p]);
545
+ labels[p] = id;
546
+ }
547
+ return added - original.size;
548
+ }
549
+ /**
550
+ * A sea province that fills a narrow channel between two land masses is cut along the middle of
551
+ * the channel by the nearest land mass, so crossing the strait takes two sea provinces. Provinces
552
+ * with more than three significant shores (island groups) are left whole.
553
+ */
554
+ function splitNarrowStraits(fields, labels, shore, narrowPx, minSide) {
555
+ const { width, height, kind } = fields;
556
+ const total = width * height;
557
+ const half = narrowPx / 2 + 1;
558
+ const widest = new Map();
559
+ for (let p = 0; p < total; p++) {
560
+ if (kind[p] !== SEA)
561
+ continue;
562
+ const label = labels[p];
563
+ const d = shore.distance[p];
564
+ const previous = widest.get(label);
565
+ if (previous === undefined || d > previous)
566
+ widest.set(label, d);
567
+ }
568
+ const counts = new Map();
569
+ for (let p = 0; p < total; p++) {
570
+ if (kind[p] !== SEA)
571
+ continue;
572
+ const label = labels[p];
573
+ if (widest.get(label) > half)
574
+ continue;
575
+ const key = label * 65_536 + shore.mass[p];
576
+ counts.set(key, (counts.get(key) ?? 0) + 1);
577
+ }
578
+ const significant = new Map();
579
+ for (const [key, count] of counts) {
580
+ if (count < minSide)
581
+ continue;
582
+ const label = Math.floor(key / 65_536);
583
+ const list = significant.get(label) ?? [];
584
+ list.push(key % 65_536);
585
+ significant.set(label, list);
586
+ }
587
+ const split = new Map();
588
+ for (const [label, masses] of significant)
589
+ if (masses.length >= 2 && masses.length <= 3)
590
+ split.set(label, masses.sort((a, b) => a - b));
591
+ if (split.size === 0)
592
+ return 0;
593
+ let maxLabel = 0;
594
+ for (let p = 0; p < total; p++)
595
+ if (labels[p] > maxLabel)
596
+ maxLabel = labels[p];
597
+ const fresh = new Map();
598
+ let created = 0;
599
+ for (let p = 0; p < total; p++) {
600
+ if (kind[p] !== SEA)
601
+ continue;
602
+ const label = labels[p];
603
+ const masses = split.get(label);
604
+ if (masses === undefined)
605
+ continue;
606
+ const mass = masses.includes(shore.mass[p]) ? shore.mass[p] : masses[0];
607
+ if (mass === masses[0])
608
+ continue;
609
+ const key = label * 65_536 + mass;
610
+ let id = fresh.get(key);
611
+ if (id === undefined) {
612
+ id = maxLabel + 1 + fresh.size;
613
+ fresh.set(key, id);
614
+ created++;
615
+ }
616
+ labels[p] = id;
617
+ }
618
+ return created;
619
+ }
620
+ /**
621
+ * Cut the map into provinces along its natural lines. Throws `MAP_PARTITION_*` for fields that
622
+ * are inconsistent or a plan that needs more than 21,000 provinces, and the legacy
623
+ * `MAP_WORLD_*` codes when the exact mask cannot be represented.
624
+ */
625
+ export function partitionProvinces(input) {
626
+ const { fields, tuning } = input;
627
+ assertPartitionFields(fields);
628
+ if (!Number.isSafeInteger(input.seed))
629
+ throw new ServiceError('MAP_PARTITION_FIELDS_INVALID', 'The partition seed must be a safe integer');
630
+ assertProvinceMaskFeasible(fields);
631
+ const { width, height, kind } = fields;
632
+ const total = width * height;
633
+ const queue = new Int32Array(total);
634
+ const landMass = labelLandMasses(kind, width, height, queue);
635
+ const shore = waterShore(kind, landMass, width, height, queue);
636
+ const coastDistance = landCoastDistance(kind, width, height, queue);
637
+ const plan = planAreas(fields, tuning, coastDistance, shore.distance);
638
+ const notes = [];
639
+ const expected = Math.round(plan.expected);
640
+ if (expected > PARTITION_PROVINCE_ERROR)
641
+ throw new ServiceError('MAP_PARTITION_PROVINCE_COUNT_HIGH', `The planned province areas give about ${expected} provinces, above the 21,000 the wiki advises and the text tables allow; raise landProvincePixels, seaProvincePixels or partitionTuning.landArea and seaArea`, { expectedProvinces: expected, maximum: PARTITION_PROVINCE_ERROR });
642
+ const { signal } = input;
643
+ signal?.throwIfAborted();
644
+ const barrier = buildBarrierField(fields, tuning, shore.distance);
645
+ const step = buildStepField(fields, barrier, tuning, input.seed);
646
+ signal?.throwIfAborted();
647
+ // Seeds: land, sea and lakes each at their own density.
648
+ const seeded = new Uint8Array(total);
649
+ const seeds = [];
650
+ const addSeeds = (pixels) => {
651
+ for (const pixel of pixels) {
652
+ seeds.push(pixel);
653
+ seeded[pixel] = 1;
654
+ }
655
+ };
656
+ addSeeds(placeSeeds({
657
+ width,
658
+ height,
659
+ kind,
660
+ wanted: LAND,
661
+ areaAt: plan.land,
662
+ minArea: Math.max(tuning.minProvincePixels, plan.minLand * 0.85),
663
+ barrier,
664
+ seed: input.seed,
665
+ salt: 17,
666
+ }));
667
+ addSeeds(placeSeeds({
668
+ width,
669
+ height,
670
+ kind,
671
+ wanted: SEA,
672
+ areaAt: plan.sea,
673
+ minArea: Math.max(tuning.minProvincePixels, plan.minSea * 0.85),
674
+ seed: input.seed,
675
+ salt: 29,
676
+ }));
677
+ addSeeds(placeSeeds({
678
+ width,
679
+ height,
680
+ kind,
681
+ wanted: LAKE,
682
+ areaAt: plan.sea,
683
+ minArea: tuning.lakeArea,
684
+ seed: input.seed,
685
+ salt: 43,
686
+ }));
687
+ for (const pixel of seedMissedComponents(kind, width, height, seeded, queue))
688
+ seeds.push(pixel);
689
+ signal?.throwIfAborted();
690
+ if (seeds.length > 65_000)
691
+ throw new ServiceError('MAP_WORLD_TOO_MANY_PROVINCES', 'The planned densities need more than 65,000 provinces; raise the province target areas', { provinces: seeds.length });
692
+ const lines = new Uint8Array(total);
693
+ for (let p = 0; p < total; p++)
694
+ if (kind[p] === LAND && (fields.riverDistance[p] === 0 || fields.barrier[p] >= 200))
695
+ lines[p] = 1;
696
+ const usesRegions = fields.regions !== undefined || fields.regionId.some((v) => v !== 0);
697
+ const labels = weightedWatershed({
698
+ width,
699
+ height,
700
+ kind,
701
+ step,
702
+ ...(usesRegions ? { regionId: fields.regionId, regionPenalty: 3_000 } : {}),
703
+ terrainClass: fields.terrainClass,
704
+ terrainPenalty: 30,
705
+ seeds: Int32Array.from(seeds),
706
+ signal,
707
+ });
708
+ const rivers = splitAcrossLines(fields, labels, MIN_PIECE_PIXELS, queue);
709
+ signal?.throwIfAborted();
710
+ const straits = splitNarrowStraits(fields, labels, shore, tuning.narrowStraitPx, Math.max(12, Math.round(tuning.minProvincePixels / 2)));
711
+ if (rivers > 0 || straits > 0)
712
+ notes.push({
713
+ code: 'MAP_PARTITION_SPLITS',
714
+ severity: 'info',
715
+ message: `Cut ${rivers} province(s) along rivers or walls and ${straits} sea province(s) along narrow straits`,
716
+ details: { riverSplits: rivers, straitSplits: straits },
717
+ });
718
+ signal?.throwIfAborted();
719
+ const provinces = assembleWorldProvinces({
720
+ width,
721
+ height,
722
+ kind,
723
+ ...(usesRegions ? { group: fields.regionId } : {}),
724
+ line: lines,
725
+ }, labels);
726
+ return { provinces, barrier, statistics: provinceStatistics(provinces, expected), notes };
727
+ }