hoi4-agent-tools 4.0.5 → 4.2.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 (104) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/canonical.js +42 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  6. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  7. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  8. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  9. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  10. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  11. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  12. package/dist/hoi4_agent_tools/core/index.js +1 -0
  13. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  14. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  15. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  16. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  17. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  18. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  19. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  20. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  21. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  22. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  23. package/dist/hoi4_agent_tools/decision/service.js +22 -1
  24. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  25. package/dist/hoi4_agent_tools/event/graph.js +52 -35
  26. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  27. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  28. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  29. package/dist/hoi4_agent_tools/event/render.js +4 -2
  30. package/dist/hoi4_agent_tools/event/service.js +93 -33
  31. package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
  32. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  33. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  34. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  35. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  36. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  37. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  38. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  40. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  41. package/dist/hoi4_agent_tools/map/render.js +9 -1
  42. package/dist/hoi4_agent_tools/map/service.js +222 -18
  43. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  44. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  45. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  46. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  47. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  48. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  49. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  50. package/dist/hoi4_agent_tools/map/world.js +374 -253
  51. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  52. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  53. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  54. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  55. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  56. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  57. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  58. package/dist/hoi4_agent_tools/version.js +2 -2
  59. package/docs/analysis.md +4 -0
  60. package/docs/events.md +12 -0
  61. package/docs/images/reference-maps/political.png +0 -0
  62. package/docs/images/reference-maps/sketch.png +0 -0
  63. package/docs/map.md +45 -0
  64. package/docs/third-party-notices.md +6 -0
  65. package/npm-shrinkwrap.json +124 -2
  66. package/package.json +4 -1
  67. package/schemas/configuration.schema.json +1 -1
  68. package/schemas/continuous-focus-palette.schema.json +1 -1
  69. package/schemas/custom-weighted-pool.schema.json +1 -1
  70. package/schemas/decision-inspect-input.schema.json +1 -1
  71. package/schemas/error-log-input.schema.json +1 -1
  72. package/schemas/event-feature-manifest.schema.json +1 -1
  73. package/schemas/focus-plan.schema.json +1 -1
  74. package/schemas/focus-planning-sidecar.schema.json +1 -1
  75. package/schemas/gui-animation-source.schema.json +1 -1
  76. package/schemas/gui-helper.schema.json +1 -1
  77. package/schemas/gui-scenario.schema.json +1 -1
  78. package/schemas/helper-expansion-request.schema.json +1 -1
  79. package/schemas/helper-expansion-summary.schema.json +1 -1
  80. package/schemas/impact-inspect-input.schema.json +1 -1
  81. package/schemas/map-operation.schema.json +373 -70
  82. package/schemas/mechanic-test-input.schema.json +1 -1
  83. package/schemas/mod-index-input.schema.json +1 -1
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +1 -1
  86. package/schemas/package-manifest.schema.json +1 -1
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/reference-context-input.schema.json +1 -1
  97. package/schemas/reference-read-input.schema.json +1 -1
  98. package/schemas/reference-search-input.schema.json +1 -1
  99. package/schemas/scenario-suite.schema.json +1 -1
  100. package/schemas/scenario-test-input.schema.json +1 -1
  101. package/schemas/script-validate-input.schema.json +1 -1
  102. package/schemas/shared-scenario.schema.json +1 -1
  103. package/schemas/source-lookup-input.schema.json +1 -1
  104. package/server.json +2 -2
@@ -0,0 +1,524 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ function invalid(message) {
3
+ throw new ServiceError('MAP_WORLD_INVALID_PROVINCES', message);
4
+ }
5
+ function hash(seed, value) {
6
+ let n = Math.imul(seed ^ value, 0x45d9f3b);
7
+ n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
8
+ return ((n ^ (n >>> 16)) >>> 0) / 0x1_0000_0000;
9
+ }
10
+ function circularDelta(x, origin, width) {
11
+ return x - origin - Math.round((x - origin) / width) * width;
12
+ }
13
+ /**
14
+ * 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.
16
+ * Each occupied sampling box has a site, including thin coasts and small islands.
17
+ */
18
+ function sites(input, wanted) {
19
+ 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)));
22
+ const columns = Math.ceil(width / cell);
23
+ const rows = Math.ceil(height / cell);
24
+ const result = {
25
+ cell,
26
+ columns,
27
+ rows,
28
+ bins: Array.from({ length: columns * rows }, () => []),
29
+ x: [],
30
+ y: [],
31
+ };
32
+ for (let by = 0; by < rows; by++) {
33
+ for (let bx = 0; bx < columns; bx++) {
34
+ const bin = by * columns + bx;
35
+ const choices = [-1, -1, -1];
36
+ const scores = [Infinity, Infinity, Infinity];
37
+ let count = 0;
38
+ let relief = 0;
39
+ let wet = 0;
40
+ for (let y = by * cell; y < Math.min(height, (by + 1) * cell); y++) {
41
+ for (let x = bx * cell; x < Math.min(width, (bx + 1) * cell); x++) {
42
+ const p = y * width + x;
43
+ if (kind[p] !== wanted)
44
+ continue;
45
+ count++;
46
+ relief += elevation[p];
47
+ wet += moisture[p];
48
+ // Independent reservoir scores avoid a preferred corner or row direction.
49
+ for (let slot = 0; slot < 3; slot++) {
50
+ const score = hash(seed ^ (wanted * 0x193) ^ (slot * 0x7919), p);
51
+ if (score < scores[slot]) {
52
+ scores[slot] = score;
53
+ choices[slot] = p;
54
+ }
55
+ }
56
+ }
57
+ }
58
+ if (count === 0)
59
+ continue;
60
+ const density = wanted === 1 ? Math.max(0, relief / count) * 1.3 + Math.max(0, wet / count) : 0;
61
+ const extra = Math.min(2, Math.floor(density + hash(seed, bin + wanted * 7717)));
62
+ const used = new Set();
63
+ for (let slot = 0; slot <= extra; slot++) {
64
+ const p = choices[slot];
65
+ if (used.has(p))
66
+ continue;
67
+ used.add(p);
68
+ result.bins[bin].push(result.x.length);
69
+ result.x.push(p % width);
70
+ result.y.push(Math.floor(p / width));
71
+ }
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function nearestPartition(input) {
77
+ const { width, height, kind, seed } = input;
78
+ const output = new Int32Array(width * height);
79
+ let offset = 0;
80
+ for (let wanted = 0; wanted < 3; wanted++) {
81
+ const grid = sites(input, wanted);
82
+ if (grid.x.length === 0)
83
+ continue;
84
+ const { cell, columns, rows } = grid;
85
+ const candidates = [];
86
+ // Bounded candidate lists keep the per-pixel work independent of province count.
87
+ for (let by = 0; by < rows; by++) {
88
+ for (let bx = 0; bx < columns; bx++) {
89
+ const list = [];
90
+ const visited = new Set();
91
+ for (let dy = -3; dy <= 3; dy++) {
92
+ if (by + dy < 0 || by + dy >= rows)
93
+ continue;
94
+ for (let dx = -3; dx <= 3; dx++) {
95
+ const column = (bx + dx + columns * 4) % columns;
96
+ const bin = (by + dy) * columns + column;
97
+ if (visited.has(bin))
98
+ continue;
99
+ visited.add(bin);
100
+ list.push(...grid.bins[bin]);
101
+ }
102
+ }
103
+ candidates.push(list);
104
+ }
105
+ }
106
+ const phase = hash(seed, wanted + 337) * Math.PI * 2;
107
+ const cycles = Math.max(1, Math.round(width / (cell * 7)));
108
+ const sx = Float64Array.from({ length: width }, (_, x) => Math.sin((x / width) * Math.PI * 2 * cycles + phase));
109
+ const cx = Float64Array.from({ length: width }, (_, x) => Math.cos((x / width) * Math.PI * 2 * (cycles + 1) + phase));
110
+ for (let y = 0; y < height; y++) {
111
+ const sy = Math.sin(y / (cell * 4.3) + phase);
112
+ const cy = Math.cos(y / (cell * 5.1) - phase);
113
+ for (let x = 0; x < width; x++) {
114
+ const p = y * width + x;
115
+ if (kind[p] !== wanted)
116
+ continue;
117
+ const qx = x + cell * 0.55 * sx[x] * sy;
118
+ const qy = y + cell * 0.55 * cx[x] * cy;
119
+ const list = candidates[Math.floor(y / cell) * columns + Math.floor(x / cell)];
120
+ let best = -1;
121
+ let distance = Infinity;
122
+ for (const id of list) {
123
+ const dx = circularDelta(grid.x[id], qx, width);
124
+ const dy = grid.y[id] - qy;
125
+ const d = dx * dx + dy * dy;
126
+ if (d < distance) {
127
+ distance = d;
128
+ best = id;
129
+ }
130
+ }
131
+ if (best < 0)
132
+ invalid('An occupied sampling box has no province site');
133
+ output[p] = offset + best;
134
+ }
135
+ }
136
+ offset += grid.x.length;
137
+ }
138
+ return output;
139
+ }
140
+ /** Split disconnected Voronoi pieces and any unusually long coastal cells in one scan. */
141
+ function connectedParts(input, labels) {
142
+ const { width, height, kind } = input;
143
+ const pixel = new Int32Array(labels.length).fill(-1);
144
+ const queue = new Int32Array(labels.length);
145
+ const parts = {
146
+ pixel,
147
+ parent: [],
148
+ size: [],
149
+ kind: [],
150
+ origin: [],
151
+ minX: [],
152
+ maxX: [],
153
+ minY: [],
154
+ maxY: [],
155
+ };
156
+ const limitX = Math.max(1, Math.floor(width / 8));
157
+ const limitY = Math.max(1, Math.floor(height / 8));
158
+ for (let start = 0; start < labels.length; start++) {
159
+ if (pixel[start] !== -1)
160
+ continue;
161
+ const id = parts.parent.length;
162
+ parts.parent.push(id);
163
+ parts.kind.push(kind[start]);
164
+ const origin = start % width;
165
+ let minX = 0;
166
+ let maxX = 0;
167
+ let minY = Math.floor(start / width);
168
+ let maxY = minY;
169
+ let head = 0;
170
+ let tail = 1;
171
+ queue[0] = start;
172
+ pixel[start] = id;
173
+ while (head < tail) {
174
+ const current = queue[head++];
175
+ const x = current % width;
176
+ const next = [
177
+ x > 0 ? current - 1 : -1,
178
+ x + 1 < width ? current + 1 : -1,
179
+ current - width,
180
+ current + width,
181
+ ];
182
+ for (const p of next) {
183
+ if (p < 0 || p >= labels.length || pixel[p] !== -1 || labels[p] !== labels[start])
184
+ continue;
185
+ const px = (p % width) - origin;
186
+ const py = Math.floor(p / width);
187
+ const nx = Math.min(minX, px);
188
+ const xx = Math.max(maxX, px);
189
+ const ny = Math.min(minY, py);
190
+ const xy = Math.max(maxY, py);
191
+ if (xx - nx + 1 > limitX || xy - ny + 1 > limitY)
192
+ continue;
193
+ minX = nx;
194
+ maxX = xx;
195
+ minY = ny;
196
+ maxY = xy;
197
+ pixel[p] = id;
198
+ queue[tail++] = p;
199
+ }
200
+ }
201
+ parts.size.push(tail);
202
+ parts.origin.push(origin);
203
+ parts.minX.push(minX);
204
+ parts.maxX.push(maxX);
205
+ parts.minY.push(minY);
206
+ parts.maxY.push(maxY);
207
+ }
208
+ return parts;
209
+ }
210
+ function root(parts, id) {
211
+ let r = id;
212
+ while (parts.parent[r] !== r)
213
+ r = parts.parent[r];
214
+ while (parts.parent[id] !== id) {
215
+ const next = parts.parent[id];
216
+ parts.parent[id] = r;
217
+ id = next;
218
+ }
219
+ return r;
220
+ }
221
+ function merge(parts, a, b, width, height) {
222
+ a = root(parts, a);
223
+ b = root(parts, b);
224
+ if (a === b)
225
+ return true;
226
+ if (parts.kind[a] !== parts.kind[b])
227
+ return false;
228
+ const shift = parts.origin[b] - parts.origin[a];
229
+ const minX = Math.min(parts.minX[a], parts.minX[b] + shift);
230
+ const maxX = Math.max(parts.maxX[a], parts.maxX[b] + shift);
231
+ const minY = Math.min(parts.minY[a], parts.minY[b]);
232
+ const maxY = Math.max(parts.maxY[a], parts.maxY[b]);
233
+ if (maxX - minX + 1 > Math.max(1, Math.floor(width / 8)))
234
+ return false;
235
+ if (maxY - minY + 1 > Math.max(1, Math.floor(height / 8)))
236
+ return false;
237
+ parts.parent[b] = a;
238
+ parts.size[a] += parts.size[b];
239
+ parts.size[b] = 0;
240
+ parts.minX[a] = minX;
241
+ parts.maxX[a] = maxX;
242
+ parts.minY[a] = minY;
243
+ parts.maxY[a] = maxY;
244
+ return true;
245
+ }
246
+ function absorbSlivers(input, parts) {
247
+ const { width, height } = input;
248
+ // 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;
264
+ }
265
+ }
266
+ if (!changed)
267
+ break;
268
+ }
269
+ }
270
+ /** Removing a pixel may not cut its donor; verify the donor's local four-way paths. */
271
+ function removable(parts, p, width, height) {
272
+ const owner = root(parts, parts.pixel[p]);
273
+ if (parts.size[owner] <= 20)
274
+ return false;
275
+ const x = p % width;
276
+ const y = Math.floor(p / width);
277
+ const cells = new Int32Array(9).fill(-1);
278
+ for (let dy = -1; dy <= 1; dy++) {
279
+ if (y + dy < 0 || y + dy >= height)
280
+ continue;
281
+ for (let dx = -1; dx <= 1; dx++) {
282
+ if (dx === 0 && dy === 0)
283
+ continue;
284
+ const q = (y + dy) * width + ((x + dx + width) % width);
285
+ if (root(parts, parts.pixel[q]) === owner)
286
+ cells[(dy + 1) * 3 + dx + 1] = q;
287
+ }
288
+ }
289
+ const cardinal = [1, 3, 5, 7].filter((i) => cells[i] !== -1);
290
+ if (cardinal.length < 2)
291
+ return true;
292
+ const visited = new Uint8Array(9);
293
+ const stack = [cardinal[0]];
294
+ visited[cardinal[0]] = 1;
295
+ while (stack.length > 0) {
296
+ const i = stack.pop();
297
+ for (const j of [i % 3 > 0 ? i - 1 : -1, i % 3 < 2 ? i + 1 : -1, i - 3, i + 3]) {
298
+ if (j < 0 || j >= 9 || cells[j] === -1 || visited[j])
299
+ continue;
300
+ visited[j] = 1;
301
+ stack.push(j);
302
+ }
303
+ }
304
+ return cardinal.every((i) => visited[i] === 1);
305
+ }
306
+ /** Grow a small fragment from removable neighboring cells when whole-province unions exceed bounds. */
307
+ function rebalanceSlivers(input, parts) {
308
+ const { width, height, kind } = input;
309
+ const fragments = new Map();
310
+ for (let p = 0; p < parts.pixel.length; p++) {
311
+ const id = root(parts, parts.pixel[p]);
312
+ if (parts.size[id] >= 20)
313
+ continue;
314
+ const cells = fragments.get(id) ?? [];
315
+ cells.push(p);
316
+ fragments.set(id, cells);
317
+ }
318
+ for (const [id, cells] of fragments) {
319
+ while (parts.size[id] < 20) {
320
+ let moved = false;
321
+ for (const p of cells) {
322
+ const x = p % width;
323
+ for (const q of [x > 0 ? p - 1 : -1, x + 1 < width ? p + 1 : -1, p - width, p + width]) {
324
+ if (q < 0 || q >= kind.length || kind[q] !== kind[p])
325
+ continue;
326
+ const donor = root(parts, parts.pixel[q]);
327
+ if (donor === id || parts.size[donor] <= 20 || !removable(parts, q, width, height))
328
+ continue;
329
+ const dx = (q % width) - parts.origin[id], y = Math.floor(q / width);
330
+ const minX = Math.min(parts.minX[id], dx), maxX = Math.max(parts.maxX[id], dx);
331
+ const minY = Math.min(parts.minY[id], y), maxY = Math.max(parts.maxY[id], y);
332
+ if (maxX - minX + 1 > width / 8 || maxY - minY + 1 > height / 8)
333
+ continue;
334
+ parts.pixel[q] = id;
335
+ parts.size[id]++;
336
+ parts.size[donor]--;
337
+ parts.minX[id] = minX;
338
+ parts.maxX[id] = maxX;
339
+ parts.minY[id] = minY;
340
+ parts.maxY[id] = maxY;
341
+ cells.push(q);
342
+ moved = true;
343
+ break;
344
+ }
345
+ if (moved)
346
+ break;
347
+ }
348
+ if (!moved)
349
+ break;
350
+ }
351
+ }
352
+ }
353
+ function repairCrossings(input, parts) {
354
+ const { width, height, kind } = input;
355
+ for (let pass = 0; pass < 12; pass++) {
356
+ let changed = false;
357
+ for (let y = 0; y + 1 < height; y++) {
358
+ for (let x = 0; x < width; x++) {
359
+ const a = y * width + x;
360
+ const b = y * width + ((x + 1) % width);
361
+ const corners = [a, b, a + width, b + width];
362
+ const ids = corners.map((p) => root(parts, parts.pixel[p]));
363
+ if (new Set(ids).size < 4)
364
+ continue;
365
+ const edges = [
366
+ [0, 1],
367
+ [0, 2],
368
+ [1, 3],
369
+ [2, 3],
370
+ ];
371
+ 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;
377
+ }
378
+ }
379
+ if (!repaired) {
380
+ for (const [i, j] of edges.flatMap(([i, j]) => [
381
+ [i, j],
382
+ [j, i],
383
+ ])) {
384
+ const p = corners[i];
385
+ const q = corners[j];
386
+ if (kind[p] !== kind[q] || !removable(parts, p, width, height))
387
+ continue;
388
+ const target = ids[j];
389
+ const dx = (p % width) - parts.origin[target];
390
+ const py = Math.floor(p / width);
391
+ const minX = Math.min(parts.minX[target], dx);
392
+ const maxX = Math.max(parts.maxX[target], dx);
393
+ const minY = Math.min(parts.minY[target], py);
394
+ const maxY = Math.max(parts.maxY[target], py);
395
+ if (maxX - minX + 1 > width / 8 || maxY - minY + 1 > height / 8)
396
+ continue;
397
+ parts.pixel[p] = target;
398
+ parts.size[target]++;
399
+ parts.size[ids[i]]--;
400
+ parts.minX[target] = minX;
401
+ parts.maxX[target] = maxX;
402
+ parts.minY[target] = minY;
403
+ parts.maxY[target] = maxY;
404
+ repaired = true;
405
+ break;
406
+ }
407
+ }
408
+ if (!repaired)
409
+ 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 });
410
+ changed = true;
411
+ }
412
+ }
413
+ if (!changed)
414
+ return;
415
+ }
416
+ throw new ServiceError('MAP_WORLD_UNREPRESENTABLE_TOPOLOGY', 'Province corner repair did not converge');
417
+ }
418
+ /**
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.
422
+ */
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.
446
+ let isolated = 0;
447
+ for (let p = 0; p < total; p++) {
448
+ const x = p % width;
449
+ const left = x > 0 ? p - 1 : p + width - 1;
450
+ const right = x + 1 < width ? p + 1 : p - width + 1;
451
+ if (kind[left] !== kind[p] &&
452
+ kind[right] !== kind[p] &&
453
+ (p < width || kind[p - width] !== kind[p]) &&
454
+ (p + width >= total || kind[p + width] !== kind[p]))
455
+ isolated++;
456
+ }
457
+ if (isolated > 65_000)
458
+ 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));
460
+ absorbSlivers(input, parts);
461
+ rebalanceSlivers(input, parts);
462
+ repairCrossings(input, parts);
463
+ const result = {
464
+ pixel: new Int32Array(total),
465
+ kind: [],
466
+ size: [],
467
+ sumX: [],
468
+ sumY: [],
469
+ neighbors: [],
470
+ };
471
+ const ids = new Int32Array(parts.parent.length).fill(-1);
472
+ const localSameKindNeighbor = new Uint8Array(parts.parent.length);
473
+ for (let p = 0; p < total; p++) {
474
+ const owner = root(parts, parts.pixel[p]);
475
+ let id = ids[owner];
476
+ if (id === -1) {
477
+ id = result.kind.length;
478
+ ids[owner] = id;
479
+ result.kind.push(kind[p]);
480
+ result.size.push(0);
481
+ result.sumX.push(0);
482
+ result.sumY.push(0);
483
+ result.neighbors.push(new Set());
484
+ }
485
+ result.pixel[p] = id;
486
+ result.size[id]++;
487
+ result.sumX[id] += p % width;
488
+ result.sumY[id] += Math.floor(p / width);
489
+ }
490
+ for (let p = 0; p < total; p++) {
491
+ const id = result.pixel[p];
492
+ const x = p % width;
493
+ for (const q of [x + 1 < width ? p + 1 : p + 1 - width, p + width]) {
494
+ if (q >= total)
495
+ continue;
496
+ const other = result.pixel[q];
497
+ if (other === id)
498
+ continue;
499
+ result.neighbors[id].add(other);
500
+ result.neighbors[other].add(id);
501
+ if ((q === p + width || x + 1 < width) && result.kind[id] === result.kind[other]) {
502
+ localSameKindNeighbor[id] = 1;
503
+ localSameKindNeighbor[other] = 1;
504
+ }
505
+ }
506
+ }
507
+ if (result.kind.length > 65_000)
508
+ throw new ServiceError('MAP_WORLD_TOO_MANY_PROVINCES', 'The world exceeds 65,000 provinces; raise the province target areas or simplify fragmented coastlines', { provinces: result.kind.length });
509
+ for (let id = 0; id < result.kind.length; id++) {
510
+ if (result.size[id] >= 20)
511
+ continue;
512
+ // A tiny component cut by the map seam has the same exception as an isolated tiny island:
513
+ // its only same-kind neighbor may be across the seam, where a native bounding box cannot merge.
514
+ if (localSameKindNeighbor[id])
515
+ throw new ServiceError('MAP_WORLD_UNREPRESENTABLE_TOPOLOGY', 'The exact kind mask leaves a province smaller than 20 pixels that cannot merge within the province extent limits', {
516
+ province: id,
517
+ pixels: result.size[id],
518
+ kind: result.kind[id],
519
+ x: result.sumX[id] / result.size[id],
520
+ y: result.sumY[id] / result.size[id],
521
+ });
522
+ }
523
+ return result;
524
+ }