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,398 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ /**
3
+ * Inputs of the feature-aware partition (ADR 0058): one byte (or two) per pixel describing the
4
+ * natural lines and the character of the land, in one typed contract. The contract exists so the
5
+ * partition does not care where the numbers come from. `partitionFieldsFromBitmaps` computes a
6
+ * basic version from the height, terrain and river bitmaps a map already has; the generator fills
7
+ * them from its own rasters and, for a geography world, from the synthesised heights, the model
8
+ * (`partitionFieldsFromGeography` adds regions and wall ranges) and the lines of the synthesis
9
+ * (`partitionFieldsWithTerrainLines`). Only `+ - * /` and `sqrt` are used, so every field is
10
+ * identical on every platform.
11
+ */
12
+ /** Terrain classes the partition distinguishes; `water` marks pixels that are not land. */
13
+ export const PARTITION_TERRAIN = {
14
+ plains: 0,
15
+ farmland: 1,
16
+ forest: 2,
17
+ hills: 3,
18
+ mountain: 4,
19
+ desert: 5,
20
+ marsh: 6,
21
+ jungle: 7,
22
+ snow: 8,
23
+ urban: 9,
24
+ water: 255,
25
+ };
26
+ /** Pixel kinds, as the province generator already uses them. */
27
+ export const PARTITION_KIND = { sea: 0, land: 1, lake: 2 };
28
+ const MAP_PIXEL_LIMIT = 13_238_272;
29
+ function invalid(message, details) {
30
+ throw new ServiceError('MAP_PARTITION_FIELDS_INVALID', message, details);
31
+ }
32
+ /** Check the sizes, kinds and region table of a set of fields; throws `MAP_PARTITION_FIELDS_INVALID`. */
33
+ export function assertPartitionFields(fields) {
34
+ const { width, height } = fields;
35
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16)
36
+ invalid('Partition dimensions must be integers of at least 16 pixels', { width, height });
37
+ const total = width * height;
38
+ if (!Number.isSafeInteger(total) || total > MAP_PIXEL_LIMIT)
39
+ invalid('Partition dimensions exceed the 13,238,272-pixel map limit', { width, height });
40
+ const planes = [
41
+ ['kind', fields.kind, Uint8Array],
42
+ ['riverDistance', fields.riverDistance, Uint8Array],
43
+ ['ridge', fields.ridge, Uint8Array],
44
+ ['slope', fields.slope, Uint8Array],
45
+ ['terrainClass', fields.terrainClass, Uint8Array],
46
+ ['regionId', fields.regionId, Uint16Array],
47
+ ['fertility', fields.fertility, Uint8Array],
48
+ ['barrier', fields.barrier, Uint8Array],
49
+ ];
50
+ for (const [name, plane, type] of planes) {
51
+ if (!(plane instanceof type))
52
+ invalid(`Partition field ${name} must be a ${type.name}`, {
53
+ field: name,
54
+ });
55
+ if (plane.length !== total)
56
+ invalid(`Partition field ${name} does not match the map dimensions`, {
57
+ field: name,
58
+ length: plane.length,
59
+ expected: total,
60
+ });
61
+ }
62
+ for (let p = 0; p < total; p++)
63
+ if (fields.kind[p] > 2)
64
+ invalid('Partition kind contains a value other than sea (0), land (1) or lake (2)', {
65
+ pixel: p,
66
+ value: fields.kind[p],
67
+ });
68
+ const ids = new Set();
69
+ for (const region of fields.regions ?? []) {
70
+ if (!Number.isInteger(region.id) || region.id < 1 || region.id > 65_535 || ids.has(region.id))
71
+ invalid('Partition region ids must be unique integers from 1 to 65,535', { id: region.id });
72
+ ids.add(region.id);
73
+ if (region.provinceAreaPx !== undefined &&
74
+ (!Number.isFinite(region.provinceAreaPx) || region.provinceAreaPx < 20))
75
+ invalid('A region province area must be at least 20 pixels', { id: region.id });
76
+ const per = region.provincesPerState;
77
+ if (per !== undefined &&
78
+ (per.some((value) => !Number.isInteger(value) || value < 1) ||
79
+ per[0] > per[1] ||
80
+ per[1] > per[2]))
81
+ invalid('A region provincesPerState must be three increasing positive integers', {
82
+ id: region.id,
83
+ });
84
+ }
85
+ if (ids.size > 0)
86
+ for (let p = 0; p < total; p++) {
87
+ const id = fields.regionId[p];
88
+ if (id !== 0 && !ids.has(id))
89
+ invalid('A pixel names a region that the region table does not list', { pixel: p, id });
90
+ }
91
+ }
92
+ // ---------------------------------------------------------------------------------------------
93
+ // Diagonal contacts
94
+ /**
95
+ * Remove the one mask pattern no partition can represent: a 2 x 2 block whose diagonals are two
96
+ * kinds (land and sea, say) so that each kind touches itself only at a corner. Four provinces
97
+ * would meet there ("X crossing"), and neither pair can be merged because the pieces are not
98
+ * four-connected. One pixel of the non-land kind becomes the other kind (land beats sea and lake, sea
99
+ * beats lake: the pixel of the weaker kind on the north-east or north-west corner changes),
100
+ * repeating until no block is left. Returns the changed pixel indices; `kind` is modified in
101
+ * place. The east-west seam wraps.
102
+ */
103
+ export function smoothDiagonalContacts(kind, width, height, maxPasses = 8) {
104
+ const changed = [];
105
+ for (let pass = 0; pass < maxPasses; pass++) {
106
+ let found = false;
107
+ for (let y = 0; y + 1 < height; y++) {
108
+ const row = y * width;
109
+ for (let x = 0; x < width; x++) {
110
+ const next = x + 1 < width ? x + 1 : 0;
111
+ const nw = row + x;
112
+ const ne = row + next;
113
+ const sw = nw + width;
114
+ const se = ne + width;
115
+ const a = kind[nw];
116
+ const b = kind[ne];
117
+ if (a === b || kind[se] !== a || kind[sw] !== b)
118
+ continue;
119
+ // The kind that survives: land over lake over sea; the other diagonal gives way.
120
+ const rank = (value) => value === PARTITION_KIND.land ? 2 : value === PARTITION_KIND.lake ? 0 : 1;
121
+ const keep = rank(a) >= rank(b) ? a : b;
122
+ const target = keep === a ? ne : nw;
123
+ kind[target] = keep;
124
+ changed.push(target);
125
+ found = true;
126
+ }
127
+ }
128
+ if (!found)
129
+ break;
130
+ }
131
+ return changed;
132
+ }
133
+ /**
134
+ * Province parts never join across the east-west seam, so a 2x2 window that straddles it holds
135
+ * four provinces whenever the kind changes vertically on both sides (a coast that crosses the
136
+ * seam). Make the east column (x = 0) agree with its upper pixel there, one pixel per crossing,
137
+ * and return the changed pixels.
138
+ */
139
+ export function repairSeamCorners(kind, width, height, maxPasses = 8) {
140
+ const changed = [];
141
+ if (width < 2)
142
+ return changed;
143
+ for (let pass = 0; pass < maxPasses; pass++) {
144
+ let found = false;
145
+ for (let y = 0; y + 1 < height; y++) {
146
+ const west = y * width + width - 1;
147
+ const east = y * width;
148
+ if (kind[west] === kind[west + width] || kind[east] === kind[east + width])
149
+ continue;
150
+ kind[east + width] = kind[east];
151
+ changed.push(east + width);
152
+ found = true;
153
+ }
154
+ if (!found)
155
+ break;
156
+ }
157
+ return changed;
158
+ }
159
+ // ---------------------------------------------------------------------------------------------
160
+ // Adapter from bitmaps
161
+ /**
162
+ * Terrain class of each graphical terrain index of the base game's `terrain.bmp`: 0 plains,
163
+ * 1 forest, 2 and 17 hills, 3 desert, 4 sparse forest, 5 farmland, 6, 16 and 20 mountain, 9 marsh,
164
+ * 13 urban, 15 ocean, 19 permanent snow, 21 and 22 jungle. Others are plains. A mod with other
165
+ * indices passes its own table (the border metric derives one from `common/terrain`).
166
+ */
167
+ export function defaultTerrainClassTable() {
168
+ const table = new Uint8Array(256).fill(PARTITION_TERRAIN.plains);
169
+ const set = (name, ...indices) => {
170
+ for (const index of indices)
171
+ table[index] = PARTITION_TERRAIN[name];
172
+ };
173
+ set('forest', 1, 4);
174
+ set('hills', 2, 17);
175
+ set('desert', 3);
176
+ set('farmland', 5);
177
+ set('mountain', 6, 16, 20);
178
+ set('marsh', 9);
179
+ set('urban', 13);
180
+ set('water', 15, 14);
181
+ set('snow', 19);
182
+ set('jungle', 21, 22);
183
+ return table;
184
+ }
185
+ /** Three-four chamfer distance in pixels (capped at 255) to the nearest pixel of `source`. */
186
+ export function chamferDistance(source, width, height) {
187
+ const cap = 255 * 3;
188
+ const distance = new Uint16Array(width * height);
189
+ let any = false;
190
+ for (let p = 0; p < distance.length; p++) {
191
+ if (source(p)) {
192
+ distance[p] = 0;
193
+ any = true;
194
+ }
195
+ else
196
+ distance[p] = cap;
197
+ }
198
+ const output = new Uint8Array(width * height);
199
+ if (!any) {
200
+ output.fill(255);
201
+ return output;
202
+ }
203
+ // The east-west seam wraps, so two sweeps of both directions reach every pixel.
204
+ for (let sweep = 0; sweep < 2; sweep++) {
205
+ for (let y = 0; y < height; y++) {
206
+ const row = y * width;
207
+ for (let x = 0; x < width; x++) {
208
+ const left = x === 0 ? width - 1 : x - 1;
209
+ const right = x === width - 1 ? 0 : x + 1;
210
+ let best = distance[row + x];
211
+ const west = distance[row + left] + 3;
212
+ if (west < best)
213
+ best = west;
214
+ if (y > 0) {
215
+ const above = row - width;
216
+ const n = distance[above + x] + 3;
217
+ const nw = distance[above + left] + 4;
218
+ const ne = distance[above + right] + 4;
219
+ if (n < best)
220
+ best = n;
221
+ if (nw < best)
222
+ best = nw;
223
+ if (ne < best)
224
+ best = ne;
225
+ }
226
+ distance[row + x] = best > cap ? cap : best;
227
+ }
228
+ }
229
+ for (let y = height - 1; y >= 0; y--) {
230
+ const row = y * width;
231
+ for (let x = width - 1; x >= 0; x--) {
232
+ const left = x === 0 ? width - 1 : x - 1;
233
+ const right = x === width - 1 ? 0 : x + 1;
234
+ let best = distance[row + x];
235
+ const east = distance[row + right] + 3;
236
+ if (east < best)
237
+ best = east;
238
+ if (y < height - 1) {
239
+ const below = row + width;
240
+ const s = distance[below + x] + 3;
241
+ const sw = distance[below + left] + 4;
242
+ const se = distance[below + right] + 4;
243
+ if (s < best)
244
+ best = s;
245
+ if (sw < best)
246
+ best = sw;
247
+ if (se < best)
248
+ best = se;
249
+ }
250
+ distance[row + x] = best > cap ? cap : best;
251
+ }
252
+ }
253
+ }
254
+ for (let p = 0; p < output.length; p++) {
255
+ const value = distance[p];
256
+ output[p] = value >= cap ? 255 : Math.min(254, Math.floor((value + 1) / 3));
257
+ }
258
+ return output;
259
+ }
260
+ /** Central-difference gradient magnitude times 16, clamped to 255; zero off land. */
261
+ export function slopeField(heights, kind, width, height) {
262
+ const output = new Uint8Array(width * height);
263
+ for (let y = 0; y < height; y++) {
264
+ const row = y * width;
265
+ const above = (y > 0 ? y - 1 : y) * width;
266
+ const below = (y < height - 1 ? y + 1 : y) * width;
267
+ for (let x = 0; x < width; x++) {
268
+ if (kind[row + x] !== PARTITION_KIND.land)
269
+ continue;
270
+ const left = x === 0 ? width - 1 : x - 1;
271
+ const right = x === width - 1 ? 0 : x + 1;
272
+ // The step down to the water is not a slope: a neighbour that is not land counts as level.
273
+ const here = heights[row + x];
274
+ const hl = kind[row + left] === PARTITION_KIND.land ? heights[row + left] : here;
275
+ const hr = kind[row + right] === PARTITION_KIND.land ? heights[row + right] : here;
276
+ const ha = kind[above + x] === PARTITION_KIND.land ? heights[above + x] : here;
277
+ const hb = kind[below + x] === PARTITION_KIND.land ? heights[below + x] : here;
278
+ const dx = hr - hl;
279
+ const dy = hb - ha;
280
+ // |gradient| = sqrt(dx^2 + dy^2) / 2 per pixel; times 16.
281
+ const value = Math.sqrt(dx * dx + dy * dy) * 8;
282
+ output[row + x] = value > 255 ? 255 : Math.round(value);
283
+ }
284
+ }
285
+ return output;
286
+ }
287
+ /**
288
+ * Ridge strength: how far a land pixel stands above the mean of its two neighbours three pixels
289
+ * away along the best of four axes, times 10. A crest line is a band a few pixels wide.
290
+ */
291
+ export function ridgeField(heights, kind, width, height, reach = 3) {
292
+ const output = new Uint8Array(width * height);
293
+ const axes = [
294
+ [1, 0],
295
+ [0, 1],
296
+ [1, 1],
297
+ [1, -1],
298
+ ];
299
+ for (let y = 0; y < height; y++) {
300
+ const row = y * width;
301
+ for (let x = 0; x < width; x++) {
302
+ const p = row + x;
303
+ if (kind[p] !== PARTITION_KIND.land)
304
+ continue;
305
+ const here = heights[p];
306
+ let best = 0;
307
+ for (const [ax, ay] of axes) {
308
+ const y0 = y - ay * reach;
309
+ const y1 = y + ay * reach;
310
+ if (y0 < 0 || y1 >= height || y0 >= height || y1 < 0)
311
+ continue;
312
+ const x0 = (((x - ax * reach) % width) + width) % width;
313
+ const x1 = (x + ax * reach) % width;
314
+ const p0 = y0 * width + x0;
315
+ const p1 = y1 * width + x1;
316
+ // Water at either end is no valley: it counts as level ground.
317
+ const h0 = kind[p0] === PARTITION_KIND.land ? heights[p0] : here;
318
+ const h1 = kind[p1] === PARTITION_KIND.land ? heights[p1] : here;
319
+ const lift = here - (h0 + h1) / 2;
320
+ if (lift > best)
321
+ best = lift;
322
+ }
323
+ const value = best * 10;
324
+ output[p] = value > 255 ? 255 : Math.round(value);
325
+ }
326
+ }
327
+ return output;
328
+ }
329
+ const FERTILITY_BASE = {
330
+ [PARTITION_TERRAIN.plains]: 150,
331
+ [PARTITION_TERRAIN.farmland]: 225,
332
+ [PARTITION_TERRAIN.forest]: 110,
333
+ [PARTITION_TERRAIN.hills]: 85,
334
+ [PARTITION_TERRAIN.mountain]: 20,
335
+ [PARTITION_TERRAIN.desert]: 25,
336
+ [PARTITION_TERRAIN.marsh]: 70,
337
+ [PARTITION_TERRAIN.jungle]: 95,
338
+ [PARTITION_TERRAIN.snow]: 5,
339
+ [PARTITION_TERRAIN.urban]: 140,
340
+ };
341
+ /**
342
+ * Fertility from terrain class, river proximity (valleys are rich), slope and optional moisture.
343
+ * The relief and climate stages replace it with their own field; the partition only reads bytes.
344
+ */
345
+ export function fertilityField(kind, terrainClass, riverDistance, slope, moisture) {
346
+ const output = new Uint8Array(kind.length);
347
+ for (let p = 0; p < kind.length; p++) {
348
+ if (kind[p] !== PARTITION_KIND.land)
349
+ continue;
350
+ let value = FERTILITY_BASE[terrainClass[p]] ?? 100;
351
+ const river = riverDistance[p];
352
+ if (river <= 14)
353
+ value += (14 - river) * 4;
354
+ value -= slope[p] * 0.5;
355
+ if (moisture !== undefined)
356
+ value += (moisture[p] - 128) * 0.25;
357
+ output[p] = value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
358
+ }
359
+ return output;
360
+ }
361
+ /** Compute the partition fields from the bitmaps a map already has. */
362
+ export function partitionFieldsFromBitmaps(input) {
363
+ const { width, height, kind } = input;
364
+ const total = width * height;
365
+ const riverMax = input.riverMaxIndex ?? 11;
366
+ const rivers = input.rivers;
367
+ const riverDistance = rivers === undefined
368
+ ? new Uint8Array(total).fill(255)
369
+ : chamferDistance((p) => rivers[p] <= riverMax && kind[p] === PARTITION_KIND.land, width, height);
370
+ const slope = input.heights === undefined
371
+ ? new Uint8Array(total)
372
+ : slopeField(input.heights, kind, width, height);
373
+ const ridge = input.heights === undefined
374
+ ? new Uint8Array(total)
375
+ : ridgeField(input.heights, kind, width, height);
376
+ const terrainClass = new Uint8Array(total).fill(PARTITION_TERRAIN.water);
377
+ const table = input.terrainClasses ?? defaultTerrainClassTable();
378
+ for (let p = 0; p < total; p++)
379
+ if (kind[p] === PARTITION_KIND.land)
380
+ terrainClass[p] =
381
+ input.terrain === undefined ? PARTITION_TERRAIN.plains : table[input.terrain[p]];
382
+ for (let p = 0; p < total; p++)
383
+ if (kind[p] === PARTITION_KIND.land && terrainClass[p] === PARTITION_TERRAIN.water)
384
+ terrainClass[p] = PARTITION_TERRAIN.plains;
385
+ return {
386
+ width,
387
+ height,
388
+ kind,
389
+ riverDistance,
390
+ ridge,
391
+ slope,
392
+ terrainClass,
393
+ regionId: input.regionId ?? new Uint16Array(total),
394
+ fertility: fertilityField(kind, terrainClass, riverDistance, slope, input.moisture),
395
+ barrier: input.barrier ?? new Uint8Array(total),
396
+ ...(input.regions === undefined ? {} : { regions: input.regions }),
397
+ };
398
+ }
@@ -0,0 +1,113 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ import { forEachPolylinePixel, rowStrips, scanAreaStrip, } from './geography/index.js';
3
+ import { PARTITION_KIND } from './partition-fields.js';
4
+ import { BARRIER_CREST, BARRIER_FORT_LINE, BARRIER_WALL, } from './relief/fields.js';
5
+ /**
6
+ * Geography inputs of the partition (ADR 0058): the named regions and seas of the model become the
7
+ * `regionId` plane and the region table (with the density each asks for), and every range that
8
+ * declares itself a wall or an impassable barrier becomes a barrier line no province may straddle.
9
+ * Relief, rivers, fertility and terrain classes stay as the caller computed them.
10
+ */
11
+ /** Pixels written by one range path per side: three pixels wide, so no 4-connected path crosses. */
12
+ const WALL_HALF_WIDTH = 1;
13
+ /**
14
+ * Return `fields` with `regionId`, `regions` and `barrier` taken from the resolved geography.
15
+ * Land regions are written on land pixels and named seas on sea pixels, so a lake is never part
16
+ * of a region by itself. Overlapping areas resolve in id order, later ids winning; ids are the
17
+ * 1-based position in the sorted list of regions that have an area, followed by the seas.
18
+ */
19
+ export function partitionFieldsFromGeography(fields, geography, options = {}) {
20
+ const { width, height, kind } = fields;
21
+ const grid = { width, height };
22
+ const regionId = new Uint16Array(fields.regionId);
23
+ const barrier = new Uint8Array(fields.barrier);
24
+ const table = [];
25
+ const areas = [];
26
+ const seaAreas = [];
27
+ let next = 1;
28
+ for (const region of geography.regions) {
29
+ if (region.area === undefined)
30
+ continue;
31
+ const entry = { id: next, kind: 'land' };
32
+ if (region.name !== undefined)
33
+ entry.name = region.name;
34
+ if (region.provinceAreaPx !== undefined)
35
+ entry.provinceAreaPx = region.provinceAreaPx;
36
+ if (region.provincesPerState !== undefined)
37
+ entry.provincesPerState = region.provincesPerState;
38
+ table.push(entry);
39
+ areas.push({ id: next, area: region.area });
40
+ next += 1;
41
+ }
42
+ for (const sea of geography.seas) {
43
+ if (next > 65_535)
44
+ break;
45
+ const entry = { id: next, kind: 'sea' };
46
+ if (sea.name !== undefined)
47
+ entry.name = sea.name;
48
+ table.push(entry);
49
+ seaAreas.push({ id: next, area: sea.area });
50
+ next += 1;
51
+ }
52
+ for (const strip of rowStrips(grid, options.stripRows ?? 256)) {
53
+ for (const { id, area } of areas)
54
+ scanAreaStrip(area, grid, strip, (y, x0, x1) => {
55
+ const row = y * width;
56
+ for (let x = x0; x < x1; x++)
57
+ if (kind[row + x] === PARTITION_KIND.land)
58
+ regionId[row + x] = id;
59
+ });
60
+ for (const { id, area } of seaAreas)
61
+ scanAreaStrip(area, grid, strip, (y, x0, x1) => {
62
+ const row = y * width;
63
+ for (let x = x0; x < x1; x++)
64
+ if (kind[row + x] === PARTITION_KIND.sea)
65
+ regionId[row + x] = id;
66
+ });
67
+ }
68
+ const whole = { y0: 0, y1: height };
69
+ for (const range of geography.ranges) {
70
+ if (range.profile !== 'wall' && range.barrier !== 'impassable')
71
+ continue;
72
+ forEachPolylinePixel(range.path, grid, whole, (x, y) => {
73
+ for (let dy = -WALL_HALF_WIDTH; dy <= WALL_HALF_WIDTH; dy++)
74
+ for (let dx = -WALL_HALF_WIDTH; dx <= WALL_HALF_WIDTH; dx++) {
75
+ if (Math.abs(dx) + Math.abs(dy) > WALL_HALF_WIDTH)
76
+ continue;
77
+ const yy = y + dy;
78
+ if (yy < 0 || yy >= height)
79
+ continue;
80
+ barrier[yy * width + ((x + dx + width) % width)] = 255;
81
+ }
82
+ });
83
+ }
84
+ return { ...fields, regionId, barrier, regions: table };
85
+ }
86
+ /** Strength each model-drawn line of the terrain synthesis gives the partition's barrier plane. */
87
+ const TERRAIN_LINE_STRENGTH = [
88
+ [BARRIER_WALL, 255],
89
+ [BARRIER_FORT_LINE, 180],
90
+ [BARRIER_CREST, 150],
91
+ ];
92
+ /**
93
+ * Return `fields` with the lines the terrain synthesis drew from the model (ADR 0056) raised in
94
+ * its `barrier` plane: a wall outside its pass gaps at 255 (a hard line no province straddles), a
95
+ * fort line at 180 and a range crest at 150. Rivers and steep ground are left to the
96
+ * `riverDistance`, `slope` and `ridge` planes, which already carry them, so they do not count
97
+ * twice. A stronger value already in the plane is kept.
98
+ */
99
+ export function partitionFieldsWithTerrainLines(fields, lines) {
100
+ const { width, height } = fields;
101
+ if (lines.width !== width || lines.height !== height)
102
+ throw new ServiceError('MAP_PARTITION_FIELDS_INVALID', 'The terrain barrier field does not match the partition dimensions', { width, height, barrier: { width: lines.width, height: lines.height } });
103
+ const barrier = new Uint8Array(fields.barrier);
104
+ for (let p = 0; p < barrier.length; p++) {
105
+ const sources = lines.sources[p];
106
+ if (sources === 0)
107
+ continue;
108
+ for (const [flag, strength] of TERRAIN_LINE_STRENGTH)
109
+ if ((sources & flag) !== 0 && strength > barrier[p])
110
+ barrier[p] = strength;
111
+ }
112
+ return { ...fields, barrier };
113
+ }
@@ -0,0 +1,165 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ /** Build the weighted graph in one pass over the province raster. */
3
+ export function buildProvinceGraph(input) {
4
+ const { width, height, pixel, barrier } = input;
5
+ const count = input.kind.length;
6
+ if (pixel.length !== width * height)
7
+ throw new ServiceError('MAP_PARTITION_GRAPH_INVALID', 'The province raster does not match the map dimensions');
8
+ if (barrier !== undefined && barrier.length !== pixel.length)
9
+ throw new ServiceError('MAP_PARTITION_GRAPH_INVALID', 'The barrier plane does not match the map dimensions');
10
+ const index = new Map();
11
+ let capacity = 1 << 14;
12
+ let lengthList = new Uint32Array(capacity);
13
+ let sumList = new Float64Array(capacity);
14
+ let fromList = new Int32Array(capacity);
15
+ let toList = new Int32Array(capacity);
16
+ let edges = 0;
17
+ const add = (a, b, strength) => {
18
+ const low = a < b ? a : b;
19
+ const high = a < b ? b : a;
20
+ const key = low * 65_536 + high;
21
+ let slot = index.get(key);
22
+ if (slot === undefined) {
23
+ slot = edges++;
24
+ if (slot === capacity) {
25
+ capacity *= 2;
26
+ const l = new Uint32Array(capacity);
27
+ l.set(lengthList);
28
+ lengthList = l;
29
+ const s = new Float64Array(capacity);
30
+ s.set(sumList);
31
+ sumList = s;
32
+ const f = new Int32Array(capacity);
33
+ f.set(fromList);
34
+ fromList = f;
35
+ const t = new Int32Array(capacity);
36
+ t.set(toList);
37
+ toList = t;
38
+ }
39
+ fromList[slot] = low;
40
+ toList[slot] = high;
41
+ index.set(key, slot);
42
+ }
43
+ lengthList[slot]++;
44
+ sumList[slot] += strength;
45
+ };
46
+ if (count > 65_535)
47
+ throw new ServiceError('MAP_PARTITION_GRAPH_INVALID', 'The graph supports 65,535 provinces');
48
+ for (let y = 0; y < height; y++) {
49
+ const row = y * width;
50
+ for (let x = 0; x < width; x++) {
51
+ const p = row + x;
52
+ const id = pixel[p];
53
+ const east = x + 1 < width ? p + 1 : row;
54
+ const other = pixel[east];
55
+ if (other !== id)
56
+ add(id, other, barrier === undefined ? 0 : Math.max(barrier[p], barrier[east]));
57
+ if (y + 1 < height) {
58
+ const south = p + width;
59
+ const below = pixel[south];
60
+ if (below !== id)
61
+ add(id, below, barrier === undefined ? 0 : Math.max(barrier[p], barrier[south]));
62
+ }
63
+ }
64
+ }
65
+ const degree = new Int32Array(count + 1);
66
+ for (let e = 0; e < edges; e++) {
67
+ degree[fromList[e] + 1]++;
68
+ degree[toList[e] + 1]++;
69
+ }
70
+ for (let i = 0; i < count; i++)
71
+ degree[i + 1] += degree[i];
72
+ const offsets = degree;
73
+ const cursor = Int32Array.from(offsets.subarray(0, count));
74
+ const to = new Int32Array(offsets[count]);
75
+ const length = new Uint32Array(offsets[count]);
76
+ const mean = new Float32Array(offsets[count]);
77
+ // Edges sorted by (low, high) keep every row ascending without a second sort.
78
+ const order = Array.from({ length: edges }, (_, i) => i).sort((a, b) => fromList[a] - fromList[b] || toList[a] - toList[b]);
79
+ for (const e of order) {
80
+ const a = fromList[e];
81
+ const b = toList[e];
82
+ const strength = sumList[e] / lengthList[e] / 255;
83
+ to[cursor[a]] = b;
84
+ length[cursor[a]] = lengthList[e];
85
+ mean[cursor[a]++] = strength;
86
+ }
87
+ for (const e of order) {
88
+ const a = fromList[e];
89
+ const b = toList[e];
90
+ const strength = sumList[e] / lengthList[e] / 255;
91
+ to[cursor[b]] = a;
92
+ length[cursor[b]] = lengthList[e];
93
+ mean[cursor[b]++] = strength;
94
+ }
95
+ // Rows now hold the high-numbered neighbours of each province, then the low-numbered ones.
96
+ sortRows(offsets, to, length, mean);
97
+ const kind = Uint8Array.from({ length: count }, (_, i) => input.kind[i]);
98
+ const size = Uint32Array.from({ length: count }, (_, i) => input.size[i]);
99
+ const centerX = new Float32Array(count);
100
+ const centerY = new Float32Array(count);
101
+ for (let i = 0; i < count; i++) {
102
+ const n = Math.max(1, size[i]);
103
+ centerX[i] = input.sumX[i] / n;
104
+ centerY[i] = input.sumY[i] / n;
105
+ }
106
+ return {
107
+ count,
108
+ kind,
109
+ offsets,
110
+ to,
111
+ length,
112
+ barrier: mean,
113
+ centerX,
114
+ centerY,
115
+ width,
116
+ height,
117
+ size,
118
+ };
119
+ }
120
+ function sortRows(offsets, to, length, barrier) {
121
+ for (let row = 0; row + 1 < offsets.length; row++) {
122
+ const start = offsets[row];
123
+ const end = offsets[row + 1];
124
+ // Insertion sort: rows hold a handful of neighbours.
125
+ for (let i = start + 1; i < end; i++) {
126
+ const t = to[i];
127
+ const l = length[i];
128
+ const b = barrier[i];
129
+ let j = i - 1;
130
+ while (j >= start && to[j] > t) {
131
+ to[j + 1] = to[j];
132
+ length[j + 1] = length[j];
133
+ barrier[j + 1] = barrier[j];
134
+ j--;
135
+ }
136
+ to[j + 1] = t;
137
+ length[j + 1] = l;
138
+ barrier[j + 1] = b;
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * Directed edge costs for growing territory over the graph: centroid distance (across the seam
144
+ * when shorter) times `1 + barrier * strength`, aligned with `graph.to`. Territory assignment
145
+ * runs its Dijkstra on these and may add its own terms.
146
+ */
147
+ export function provinceEdgeCosts(graph, options = {}) {
148
+ const beta = options.barrier ?? 6;
149
+ const water = options.water ?? 3;
150
+ const cost = new Float32Array(graph.to.length);
151
+ for (let a = 0; a < graph.count; a++)
152
+ for (let e = graph.offsets[a]; e < graph.offsets[a + 1]; e++) {
153
+ const b = graph.to[e];
154
+ let dx = graph.centerX[a] - graph.centerX[b];
155
+ if (dx > graph.width / 2)
156
+ dx -= graph.width;
157
+ else if (dx < -graph.width / 2)
158
+ dx += graph.width;
159
+ const dy = graph.centerY[a] - graph.centerY[b];
160
+ const distance = Math.sqrt(dx * dx + dy * dy);
161
+ cost[e] =
162
+ distance * (1 + beta * graph.barrier[e]) * (graph.kind[a] === graph.kind[b] ? 1 : water);
163
+ }
164
+ return cost;
165
+ }