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,101 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ /** Defaults relative to the base land area, from the vanilla spread (median 160, p10 77, p90 846). */
3
+ export const PARTITION_DEFAULTS = {
4
+ fertile: 0.35,
5
+ hills: 1,
6
+ mountain: 2.2,
7
+ waste: 3.4,
8
+ seaCoastal: 0.5,
9
+ seaOpen: 2.4,
10
+ lake: 6_000,
11
+ barrierStrength: 20,
12
+ minProvincePixels: 40,
13
+ narrowStraitPx: 24,
14
+ };
15
+ /** The province count above which the wiki and the text-table limit advise against a map. */
16
+ export const PARTITION_PROVINCE_WARNING = 14_000;
17
+ export const PARTITION_PROVINCE_ERROR = 21_000;
18
+ function refuse(code, message, details) {
19
+ throw new ServiceError(code, message, details);
20
+ }
21
+ function checkPixels(name, value, minimum) {
22
+ if (value === undefined)
23
+ return;
24
+ if (!Number.isFinite(value) || value < minimum)
25
+ refuse('MAP_PARTITION_AREA_BANDS_CONTRADICTORY', `partition ${name} must be a finite number of at least ${minimum} pixels`, { field: name, value, minimum });
26
+ }
27
+ /**
28
+ * Resolve and check the tuning against the request. Contradictory size bands, a map too small for
29
+ * the minimum province, or an ordering that cannot be met are refused before any pixel is
30
+ * touched, with `MAP_PARTITION_*` codes.
31
+ */
32
+ export function resolvePartitionTuning(tuning, base) {
33
+ const minProvincePixels = tuning?.minProvincePixels ?? PARTITION_DEFAULTS.minProvincePixels;
34
+ checkPixels('minProvincePixels', minProvincePixels, 20);
35
+ const cellLimit = Math.floor(Math.min(base.width, base.height) / 14) ** 2;
36
+ if (minProvincePixels > cellLimit)
37
+ refuse('MAP_PARTITION_MAP_TOO_SMALL', `The map is too small for provinces of at least ${minProvincePixels} pixels: a province may span at most an eighth of the map, about ${cellLimit} pixels here`, { minProvincePixels, maximum: cellLimit, width: base.width, height: base.height });
38
+ const clamp = (value) => Math.max(minProvincePixels, Math.min(cellLimit, Math.round(value)));
39
+ const land = base.landProvincePixels;
40
+ const bands = tuning?.landArea;
41
+ for (const [name, value] of Object.entries(bands ?? {}))
42
+ checkPixels(`landArea.${name}`, value, minProvincePixels);
43
+ const fertile = clamp(bands?.fertile ?? land * PARTITION_DEFAULTS.fertile);
44
+ const hills = clamp(bands?.hills ?? land * PARTITION_DEFAULTS.hills);
45
+ const mountain = clamp(bands?.mountain ?? land * PARTITION_DEFAULTS.mountain);
46
+ const waste = clamp(bands?.waste ?? land * PARTITION_DEFAULTS.waste);
47
+ if (fertile > hills || hills > mountain)
48
+ refuse('MAP_PARTITION_AREA_BANDS_CONTRADICTORY', 'partition landArea must grow from fertile to hills to mountain (fertile <= hills <= mountain)', { fertile, hills, mountain, waste });
49
+ const sea = tuning?.seaArea;
50
+ for (const [name, value] of Object.entries(sea ?? {}))
51
+ checkPixels(`seaArea.${name}`, value, minProvincePixels);
52
+ const coastal = clamp(sea?.coastal ?? base.seaProvincePixels * PARTITION_DEFAULTS.seaCoastal);
53
+ const open = clamp(sea?.open ?? base.seaProvincePixels * PARTITION_DEFAULTS.seaOpen);
54
+ if (coastal > open)
55
+ refuse('MAP_PARTITION_AREA_BANDS_CONTRADICTORY', 'partition seaArea.coastal may not exceed seaArea.open', { coastal, open });
56
+ const perState = tuning?.provincesPerState;
57
+ if (perState !== undefined && perState[0] > perState[1])
58
+ refuse('MAP_PARTITION_STATE_SIZE_CONTRADICTORY', 'partition provincesPerState must be [smallest, largest] with smallest <= largest', { provincesPerState: perState });
59
+ const regionSize = tuning?.regionSize;
60
+ if (regionSize !== undefined && regionSize[0] > regionSize[1])
61
+ refuse('MAP_PARTITION_REGION_SIZE_CONTRADICTORY', 'partition regionSize must be [smallest, largest] with smallest <= largest', { regionSize });
62
+ const group = (range, target, low, high) => {
63
+ if (range !== undefined)
64
+ return {
65
+ min: range[0],
66
+ target: Math.max(range[0], Math.min(range[1], Math.round(Math.sqrt(range[0] * range[1])))),
67
+ max: range[1],
68
+ };
69
+ return {
70
+ min: Math.max(1, Math.round(target * low)),
71
+ target,
72
+ max: Math.max(target, Math.round(target * high)) + (target > 1 ? 1 : 0),
73
+ };
74
+ };
75
+ const strength = tuning?.barrierStrength ?? PARTITION_DEFAULTS.barrierStrength;
76
+ if (!Number.isFinite(strength) || strength < 0 || strength > 60)
77
+ refuse('MAP_PARTITION_STRENGTH_INVALID', 'partition barrierStrength must be from 0 to 60', {
78
+ barrierStrength: strength,
79
+ });
80
+ const strait = tuning?.narrowStraitPx ?? PARTITION_DEFAULTS.narrowStraitPx;
81
+ if (!Number.isInteger(strait) || strait < 4 || strait > 64)
82
+ refuse('MAP_PARTITION_STRENGTH_INVALID', 'partition narrowStraitPx must be from 4 to 64', {
83
+ narrowStraitPx: strait,
84
+ });
85
+ return {
86
+ landArea: { fertile, plains: clamp(Math.sqrt(fertile * hills)), hills, mountain, waste },
87
+ seaArea: { coastal, open },
88
+ lakeArea: clamp(PARTITION_DEFAULTS.lake),
89
+ states: group(perState, base.provincesPerState, 0.5, 1.6),
90
+ regions: group(regionSize, base.landProvincesPerRegion, 0.6, 1.5),
91
+ seaRegions: group(undefined, base.seaProvincesPerRegion, 0.5, 1.6),
92
+ barrierStrength: strength,
93
+ minProvincePixels,
94
+ narrowStraitPx: strait,
95
+ maxProvincePixels: cellLimit,
96
+ normalizeLand: bands === undefined || Object.keys(bands).length === 0,
97
+ normalizeSea: sea === undefined || Object.keys(sea).length === 0,
98
+ landBase: land,
99
+ seaBase: base.seaProvincePixels,
100
+ };
101
+ }
@@ -0,0 +1,252 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ /**
3
+ * The two engines of the feature-aware province partition (ADR 0058): variable-radius dart
4
+ * throwing for seeds and a marker-controlled watershed (multi-source Dijkstra with integer costs
5
+ * in a bucket queue) for borders. Both are deterministic: hashed priorities, no random state, no
6
+ * floating-point function beyond `+ - * /` and `sqrt`.
7
+ */
8
+ export function hash32(seed, a, b = 0) {
9
+ let n = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(a + 0x7f4a7c15, 0xc2b2ae35);
10
+ n = Math.imul(n ^ (n >>> 15), 0x2c1b3c6d) ^ Math.imul(b + 0x165667b1, 0x297a2d39);
11
+ n = Math.imul(n ^ (n >>> 12), 0x85ebca6b);
12
+ n = Math.imul(n ^ (n >>> 15), 0xc2b2ae35);
13
+ return (n ^ (n >>> 16)) >>> 0;
14
+ }
15
+ export function unit32(value) {
16
+ return value / 4_294_967_296;
17
+ }
18
+ /**
19
+ * Candidates per final seed. Random sequential adsorption reaches about 94% of the jamming
20
+ * density after ten attempts per disc, which fills the map without visible gaps.
21
+ */
22
+ const OVERSAMPLE = 40;
23
+ /** Dart-throwing spacing over the square root of the planned area (jamming gives 0.834). */
24
+ export const SEED_SPACING = 0.8;
25
+ /**
26
+ * Variable-radius Poisson-disc seeds: candidates are drawn per lattice cell with a probability
27
+ * that follows the planned area, ordered by a hashed priority, and accepted when no accepted seed
28
+ * lies closer than the smaller of the two spacings. Returns pixel indices in acceptance order.
29
+ */
30
+ export function placeSeeds(request) {
31
+ const { width, height, kind, wanted, areaAt, minArea, seed, salt } = request;
32
+ const stride = Math.max(1, Math.floor(0.35 * SEED_SPACING * Math.sqrt(minArea)));
33
+ const cx = [];
34
+ const spacing = [];
35
+ const keys = [];
36
+ const limit = request.avoidBarrierAbove ?? 64;
37
+ for (let y0 = 0; y0 < height; y0 += stride) {
38
+ for (let x0 = 0; x0 < width; x0 += stride) {
39
+ const h = hash32(seed + salt, x0 * 0x10001 + 1, y0);
40
+ const x = Math.min(width - 1, x0 + (h % stride));
41
+ const y = Math.min(height - 1, y0 + (((h >>> 8) >>> 0) % stride));
42
+ const pixel = y * width + x;
43
+ if (kind[pixel] !== wanted)
44
+ continue;
45
+ if (request.barrier !== undefined && request.barrier[pixel] > limit)
46
+ continue;
47
+ const area = areaAt(pixel);
48
+ const chance = (OVERSAMPLE * stride * stride) / area;
49
+ if (chance < 1 && unit32(hash32(seed + salt, pixel, 7)) >= chance)
50
+ continue;
51
+ cx.push(pixel);
52
+ spacing.push(SEED_SPACING * Math.sqrt(area));
53
+ keys.push(hash32(seed + salt, pixel, 13) >>> 4);
54
+ }
55
+ }
56
+ const count = cx.length;
57
+ if (count === 0)
58
+ return new Int32Array(0);
59
+ if (count >= 1 << 24)
60
+ throw new ServiceError('MAP_PARTITION_TOO_MANY_CANDIDATES', 'The planned province areas are too small for this map', { candidates: count });
61
+ const order = new Float64Array(count);
62
+ for (let i = 0; i < count; i++)
63
+ order[i] = keys[i] * 16_777_216 + i;
64
+ order.sort();
65
+ let smallest = Infinity;
66
+ for (const value of spacing)
67
+ if (value < smallest)
68
+ smallest = value;
69
+ const cell = smallest / Math.SQRT2;
70
+ const columns = Math.ceil(width / cell) + 1;
71
+ const rows = Math.ceil(height / cell) + 1;
72
+ const grid = new Int32Array(columns * rows).fill(-1);
73
+ const seedPixels = [];
74
+ const seedSpacing = [];
75
+ for (let k = 0; k < count; k++) {
76
+ const index = order[k] % 16_777_216;
77
+ const pixel = cx[index];
78
+ const d = spacing[index];
79
+ const x = pixel % width;
80
+ const y = (pixel - x) / width;
81
+ const gx = Math.floor(x / cell);
82
+ const gy = Math.floor(y / cell);
83
+ const reach = Math.ceil(d / cell);
84
+ let free = true;
85
+ for (let dy = -reach; dy <= reach && free; dy++) {
86
+ const row = gy + dy;
87
+ if (row < 0 || row >= rows)
88
+ continue;
89
+ for (let dx = -reach; dx <= reach; dx++) {
90
+ let column = gx + dx;
91
+ // The map wraps east-west.
92
+ const wrapped = Math.ceil(width / cell);
93
+ if (column < 0)
94
+ column += wrapped;
95
+ else if (column >= wrapped)
96
+ column -= wrapped;
97
+ const other = grid[row * columns + column];
98
+ if (other < 0)
99
+ continue;
100
+ const ox = seedPixels[other] % width;
101
+ const oy = (seedPixels[other] - ox) / width;
102
+ let ddx = ox - x;
103
+ if (ddx > width / 2)
104
+ ddx -= width;
105
+ else if (ddx < -width / 2)
106
+ ddx += width;
107
+ const ddy = oy - y;
108
+ const near = Math.min(d, seedSpacing[other]);
109
+ if (ddx * ddx + ddy * ddy < near * near) {
110
+ free = false;
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ if (!free)
116
+ continue;
117
+ grid[gy * columns + gx] = seedPixels.length;
118
+ seedPixels.push(pixel);
119
+ seedSpacing.push(d);
120
+ }
121
+ return Int32Array.from(seedPixels);
122
+ }
123
+ /** A plain step has this multiplier, and costs 10 for a cardinal move and 14 for a diagonal one. */
124
+ export const STEP_UNIT = 256;
125
+ /**
126
+ * Label every pixel with the seed that reaches it at the least cost. The cost of a move onto pixel
127
+ * `q` is `base * step[q] / 256` plus a penalty when the region or terrain class changes, so
128
+ * wavefronts slow down on natural lines and meet on them. Ties go to the first arrival. The
129
+ * result labels exactly the pixels connected to a seed through pixels of one kind (eight
130
+ * neighbours, wrapping east-west).
131
+ */
132
+ export function weightedWatershed(input) {
133
+ const { width, height, kind, step, seeds } = input;
134
+ const total = width * height;
135
+ const regionPenalty = input.regionPenalty ?? 0;
136
+ const terrainPenalty = input.terrainPenalty ?? 0;
137
+ const region = input.regionId;
138
+ const terrain = input.terrainClass;
139
+ let maxStep = 0;
140
+ for (let p = 0; p < total; p++)
141
+ if (step[p] > maxStep)
142
+ maxStep = step[p];
143
+ const maxCost = ((14 * maxStep) >> 8) + 1 + regionPenalty + terrainPenalty;
144
+ let buckets = 1;
145
+ while (buckets <= maxCost)
146
+ buckets <<= 1;
147
+ const mask = buckets - 1;
148
+ const heads = new Int32Array(buckets).fill(-1);
149
+ let capacity = Math.max(1 << 16, seeds.length * 4);
150
+ let nodePixel = new Int32Array(capacity);
151
+ let nodeNext = new Int32Array(capacity);
152
+ let used = 0;
153
+ let free = -1;
154
+ let live = 0;
155
+ const distance = new Int32Array(total).fill(0x7fffffff);
156
+ const labels = new Int32Array(total).fill(-1);
157
+ const push = (cost, pixel) => {
158
+ let node;
159
+ if (free >= 0) {
160
+ node = free;
161
+ free = nodeNext[node];
162
+ }
163
+ else {
164
+ if (used === capacity) {
165
+ capacity *= 2;
166
+ const pixels = new Int32Array(capacity);
167
+ pixels.set(nodePixel);
168
+ nodePixel = pixels;
169
+ const next = new Int32Array(capacity);
170
+ next.set(nodeNext);
171
+ nodeNext = next;
172
+ }
173
+ node = used++;
174
+ }
175
+ const bucket = cost & mask;
176
+ nodePixel[node] = pixel;
177
+ nodeNext[node] = heads[bucket];
178
+ heads[bucket] = node;
179
+ live++;
180
+ };
181
+ for (let i = 0; i < seeds.length; i++) {
182
+ const p = seeds[i];
183
+ if (labels[p] !== -1)
184
+ continue;
185
+ labels[p] = i;
186
+ distance[p] = 0;
187
+ push(0, p);
188
+ }
189
+ const relax = (q, from, current, base, label) => {
190
+ let cost = current + ((base * step[q]) >> 8);
191
+ if (region !== undefined && region[q] !== region[from])
192
+ cost += regionPenalty;
193
+ if (terrain !== undefined && terrain[q] !== terrain[from])
194
+ cost += terrainPenalty;
195
+ if (cost < distance[q]) {
196
+ distance[q] = cost;
197
+ labels[q] = label;
198
+ push(cost, q);
199
+ }
200
+ };
201
+ const last = width - 1;
202
+ const signal = input.signal;
203
+ let settled = 0;
204
+ for (let current = 0; live > 0; current++) {
205
+ const bucket = current & mask;
206
+ while (heads[bucket] !== -1) {
207
+ const node = heads[bucket];
208
+ heads[bucket] = nodeNext[node];
209
+ const p = nodePixel[node];
210
+ nodeNext[node] = free;
211
+ free = node;
212
+ live--;
213
+ if (distance[p] !== current)
214
+ continue;
215
+ if ((++settled & 0xffff) === 0)
216
+ signal?.throwIfAborted();
217
+ const y = (p / width) | 0;
218
+ const x = p - y * width;
219
+ const label = labels[p];
220
+ const here = kind[p];
221
+ const west = x > 0 ? p - 1 : p + last;
222
+ const east = x < last ? p + 1 : p - last;
223
+ if (kind[west] === here)
224
+ relax(west, p, current, 10, label);
225
+ if (kind[east] === here)
226
+ relax(east, p, current, 10, label);
227
+ if (y > 0) {
228
+ const north = p - width;
229
+ const northWest = x > 0 ? north - 1 : north + last;
230
+ const northEast = x < last ? north + 1 : north - last;
231
+ if (kind[north] === here)
232
+ relax(north, p, current, 10, label);
233
+ if (kind[northWest] === here)
234
+ relax(northWest, p, current, 14, label);
235
+ if (kind[northEast] === here)
236
+ relax(northEast, p, current, 14, label);
237
+ }
238
+ if (y < height - 1) {
239
+ const south = p + width;
240
+ const southWest = x > 0 ? south - 1 : south + last;
241
+ const southEast = x < last ? south + 1 : south - last;
242
+ if (kind[south] === here)
243
+ relax(south, p, current, 10, label);
244
+ if (kind[southWest] === here)
245
+ relax(southWest, p, current, 14, label);
246
+ if (kind[southEast] === here)
247
+ relax(southEast, p, current, 14, label);
248
+ }
249
+ }
250
+ }
251
+ return labels;
252
+ }
@@ -0,0 +1,163 @@
1
+ import { buildProvinceGraph } from './partition-graph.js';
2
+ import { clusterProvinces } from './partition-groups.js';
3
+ import { PARTITION_KIND, PARTITION_TERRAIN } from './partition-fields.js';
4
+ import { partitionProvinces, } from './partition-provinces.js';
5
+ import { resolvePartitionTuning } from './partition-tuning.js';
6
+ /**
7
+ * Which partition a request gets. A request that names one gets it; otherwise a world described
8
+ * by a geography model gets the feature-aware partition (the model has regions, walls and
9
+ * densities that only it can use) and every other request keeps the legacy partition, so its
10
+ * output stays byte-identical for a seed.
11
+ */
12
+ export function resolvePartitionMode(requested, hasGeography) {
13
+ return requested ?? (hasGeography ? 'feature-aware' : 'legacy');
14
+ }
15
+ /** The class or region holding most pixels of every province. */
16
+ function majorityPerProvince(pixel, plane, count, include, empty) {
17
+ const best = new Uint16Array(count).fill(empty);
18
+ const bestCount = new Uint32Array(count);
19
+ const tally = new Map();
20
+ let lastKey = -1;
21
+ let run = 0;
22
+ const flush = () => {
23
+ if (lastKey < 0)
24
+ return;
25
+ const total = (tally.get(lastKey) ?? 0) + run;
26
+ tally.set(lastKey, total);
27
+ const id = Math.floor(lastKey / 65_536);
28
+ const value = lastKey % 65_536;
29
+ if (total > bestCount[id] || (total === bestCount[id] && value < best[id])) {
30
+ bestCount[id] = total;
31
+ best[id] = value;
32
+ }
33
+ run = 0;
34
+ };
35
+ for (let p = 0; p < pixel.length; p++) {
36
+ if (!include(p))
37
+ continue;
38
+ const key = pixel[p] * 65_536 + plane[p];
39
+ if (key !== lastKey) {
40
+ flush();
41
+ lastKey = key;
42
+ }
43
+ run++;
44
+ }
45
+ flush();
46
+ return best;
47
+ }
48
+ /** Cut a map into provinces along its natural lines and build the weighted province graph. */
49
+ export function partitionWorld(input) {
50
+ const { fields } = input;
51
+ const result = partitionProvinces(input);
52
+ input.signal?.throwIfAborted();
53
+ const { provinces } = result;
54
+ const graph = buildProvinceGraph({
55
+ width: fields.width,
56
+ height: fields.height,
57
+ pixel: provinces.pixel,
58
+ kind: provinces.kind,
59
+ size: provinces.size,
60
+ sumX: provinces.sumX,
61
+ sumY: provinces.sumY,
62
+ barrier: result.barrier,
63
+ });
64
+ const count = provinces.kind.length;
65
+ const provinceRegion = majorityPerProvince(provinces.pixel, fields.regionId, count, (p) => fields.kind[p] !== PARTITION_KIND.lake, 0);
66
+ const provinceGround = Uint8Array.from(majorityPerProvince(provinces.pixel, fields.terrainClass, count, (p) => fields.kind[p] === PARTITION_KIND.land, PARTITION_TERRAIN.water));
67
+ for (let id = 0; id < count; id++)
68
+ if (provinces.kind[id] === PARTITION_KIND.sea) {
69
+ let shelf = false;
70
+ for (let e = graph.offsets[id]; e < graph.offsets[id + 1]; e++)
71
+ if (graph.kind[graph.to[e]] === PARTITION_KIND.land)
72
+ shelf = true;
73
+ provinceGround[id] = shelf ? 0 : 1;
74
+ }
75
+ return {
76
+ fields,
77
+ tuning: input.tuning,
78
+ provinces,
79
+ graph,
80
+ barrier: result.barrier,
81
+ provinceRegion,
82
+ provinceGround,
83
+ statistics: result.statistics,
84
+ notes: result.notes,
85
+ };
86
+ }
87
+ /** Resolve tuning against a request and partition in one call. */
88
+ export function partitionWorldFromRequest(fields, seed, tuning, base, signal) {
89
+ return partitionWorld({
90
+ fields,
91
+ seed,
92
+ signal,
93
+ tuning: resolvePartitionTuning(tuning, {
94
+ ...base,
95
+ width: fields.width,
96
+ height: fields.height,
97
+ }),
98
+ });
99
+ }
100
+ /**
101
+ * Cluster land provinces into states. `members` are one country's provinces of one strategic
102
+ * region (or any group the caller chose); the clusters follow natural lines, never join provinces
103
+ * of different geography regions and prefer like ground. Returns the cluster of every member,
104
+ * numbered from 0 by smallest member id.
105
+ */
106
+ export function clusterStates(partition, members) {
107
+ const { states } = partition.tuning;
108
+ const region = partition.provinceRegion;
109
+ const ground = partition.provinceGround;
110
+ const table = new Map((partition.fields.regions ?? []).map((entry) => [entry.id, entry]));
111
+ // A geography region may ask for its own state size; its provinces never mix with another's.
112
+ const byRegion = new Map();
113
+ for (const id of members) {
114
+ const list = byRegion.get(region[id]) ?? [];
115
+ list.push(id);
116
+ byRegion.set(region[id], list);
117
+ }
118
+ const combined = new Map();
119
+ let offset = 0;
120
+ for (const regionId of [...byRegion.keys()].sort((x, y) => x - y)) {
121
+ const per = table.get(regionId)?.provincesPerState;
122
+ const options = per === undefined
123
+ ? { min: states.min, target: states.target, max: states.max }
124
+ : { min: per[0], target: per[1], max: Math.max(per[2], per[1]) };
125
+ const clusters = clusterProvinces(byRegion.get(regionId), partition.graph, options, {
126
+ label: (id) => ground[id],
127
+ });
128
+ let largest = -1;
129
+ for (const [id, cluster] of clusters) {
130
+ combined.set(id, offset + cluster);
131
+ if (cluster > largest)
132
+ largest = cluster;
133
+ }
134
+ offset += largest + 1;
135
+ }
136
+ // Number the clusters by their smallest member, as every clustering here does.
137
+ const dense = new Map();
138
+ const result = new Map();
139
+ for (const id of [...members].sort((x, y) => x - y)) {
140
+ const cluster = combined.get(id);
141
+ let number = dense.get(cluster);
142
+ if (number === undefined) {
143
+ number = dense.size;
144
+ dense.set(cluster, number);
145
+ }
146
+ result.set(id, number);
147
+ }
148
+ return result;
149
+ }
150
+ /**
151
+ * Cluster provinces into strategic regions: land by basin and ground inside each geography
152
+ * region, sea by named sea and by shelf against open water. Pass the land provinces or the sea
153
+ * provinces as `members`.
154
+ */
155
+ export function clusterRegions(partition, members, kind) {
156
+ const options = kind === 'land' ? partition.tuning.regions : partition.tuning.seaRegions;
157
+ const region = partition.provinceRegion;
158
+ const ground = partition.provinceGround;
159
+ return clusterProvinces(members, partition.graph, { min: options.min, target: options.target, max: options.max }, {
160
+ group: (id) => region[id],
161
+ label: (id) => ground[id],
162
+ });
163
+ }