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.
- package/CHANGELOG.md +33 -1
- package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
- package/dist/hoi4_agent_tools/core/configuration.js +11 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
- package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
- package/dist/hoi4_agent_tools/core/transactions.js +23 -4
- package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
- package/dist/hoi4_agent_tools/core/workspace.js +42 -6
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
- package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
- package/dist/hoi4_agent_tools/map/bmp.js +9 -1
- package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
- package/dist/hoi4_agent_tools/map/colormap.js +128 -0
- package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
- package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
- package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
- package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
- package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
- package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
- package/dist/hoi4_agent_tools/map/model.js +17 -3
- package/dist/hoi4_agent_tools/map/operations.js +284 -14
- package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
- package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
- package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
- package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
- package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
- package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
- package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
- package/dist/hoi4_agent_tools/map/partition.js +163 -0
- package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
- package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
- package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
- package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
- package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
- package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
- package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
- package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
- package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
- package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
- package/dist/hoi4_agent_tools/map/render.js +36 -0
- package/dist/hoi4_agent_tools/map/service.js +206 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
- package/dist/hoi4_agent_tools/map/validation.js +9 -0
- package/dist/hoi4_agent_tools/map/water.js +227 -0
- package/dist/hoi4_agent_tools/map/world-context.js +47 -1
- package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
- package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +562 -163
- package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
- package/dist/hoi4_agent_tools/reference/operations.js +3 -2
- package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
- package/dist/hoi4_agent_tools/schemas/map.js +280 -15
- package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/configuration.md +17 -14
- package/docs/focus.md +6 -0
- package/docs/map.md +292 -15
- package/docs/tools.md +4 -4
- package/docs/troubleshooting.md +11 -11
- package/npm-shrinkwrap.json +109 -91
- package/package.json +3 -3
- package/schemas/configuration.schema.json +65 -58
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/geography-model.schema.json +1148 -0
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +5 -11
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +2325 -292
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +25 -7
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/reference-sheets.schema.json +267 -0
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Deterministic value noise shared by world generation. Only + - * / are used, so the output is
|
|
2
|
+
* identical on every platform. */
|
|
3
|
+
export function lattice(seed, x, y) {
|
|
4
|
+
let hash = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 982451653);
|
|
5
|
+
hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
|
|
6
|
+
return ((hash ^ (hash >>> 16)) >>> 0) / 4294967296;
|
|
7
|
+
}
|
|
8
|
+
export function smooth(t) {
|
|
9
|
+
return t * t * (3 - 2 * t);
|
|
10
|
+
}
|
|
11
|
+
export function valueNoise(seed, x, y) {
|
|
12
|
+
const x0 = Math.floor(x);
|
|
13
|
+
const y0 = Math.floor(y);
|
|
14
|
+
const tx = smooth(x - x0);
|
|
15
|
+
const ty = smooth(y - y0);
|
|
16
|
+
const a = lattice(seed, x0, y0);
|
|
17
|
+
const b = lattice(seed, x0 + 1, y0);
|
|
18
|
+
const c = lattice(seed, x0, y0 + 1);
|
|
19
|
+
const d = lattice(seed, x0 + 1, y0 + 1);
|
|
20
|
+
return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
|
|
21
|
+
}
|
|
22
|
+
export function fractal(seed, x, y, scale, octaves = 5) {
|
|
23
|
+
let total = 0;
|
|
24
|
+
let amplitude = 1;
|
|
25
|
+
let weight = 0;
|
|
26
|
+
let frequency = 1 / scale;
|
|
27
|
+
for (let octave = 0; octave < octaves; octave += 1) {
|
|
28
|
+
total += valueNoise(seed + octave * 101, x * frequency, y * frequency) * amplitude;
|
|
29
|
+
weight += amplitude;
|
|
30
|
+
amplitude *= 0.5;
|
|
31
|
+
frequency *= 2;
|
|
32
|
+
}
|
|
33
|
+
return total / weight;
|
|
34
|
+
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { planManagedOutputs } from '../core/managed-outputs.js';
|
|
3
3
|
export const WORLD_OUTPUT_MANIFEST = '.hoi4-agent-world.json';
|
|
4
|
+
/** Country outputs of a generated world: tags, country files, histories, units, flags, bookmark. */
|
|
5
|
+
export function isWorldCountryOutput(relativePath) {
|
|
6
|
+
const dir = path.posix.dirname(relativePath), file = path.posix.basename(relativePath);
|
|
7
|
+
return ((['common/countries', 'history/countries'].includes(dir) &&
|
|
8
|
+
/^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
|
|
9
|
+
(dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
|
|
10
|
+
(['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
|
|
11
|
+
/^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
|
|
12
|
+
[
|
|
13
|
+
'common/countries/colors.txt',
|
|
14
|
+
'common/country_tags/zz_world_countries.txt',
|
|
15
|
+
'common/bookmarks/world.txt',
|
|
16
|
+
].includes(relativePath));
|
|
17
|
+
}
|
|
4
18
|
export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots) {
|
|
19
|
+
return planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots)
|
|
20
|
+
.changes;
|
|
21
|
+
}
|
|
22
|
+
export function planWorldOutputs(sources, proposed, operationId, mapRoot, stateRoots, localisationRoots, options = {}) {
|
|
5
23
|
const mapFiles = new Set([
|
|
6
24
|
'provinces.bmp',
|
|
7
25
|
'definition.csv',
|
|
@@ -29,15 +47,8 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
|
|
|
29
47
|
(dir === `${mapRoot}/terrain` && /^colormap_[a-z0-9_]+\.dds$/u.test(file)) ||
|
|
30
48
|
(dir === `${mapRoot}/strategicregions` && /^\d+.*\.txt$/u.test(file)) ||
|
|
31
49
|
(stateRoots.includes(dir) && /^\d+.*\.txt$/u.test(file)) ||
|
|
32
|
-
(
|
|
33
|
-
/^[A-Z][A-Z0-9]{2} - [^/]+\.txt$/u.test(file)) ||
|
|
34
|
-
(dir === 'history/units' && /^[A-Z][A-Z0-9]{2}_world\.txt$/u.test(file)) ||
|
|
35
|
-
(['gfx/flags', 'gfx/flags/medium', 'gfx/flags/small'].includes(dir) &&
|
|
36
|
-
/^[A-Z][A-Z0-9]{2}\.tga$/u.test(file)) ||
|
|
50
|
+
isWorldCountryOutput(name) ||
|
|
37
51
|
[
|
|
38
|
-
'common/countries/colors.txt',
|
|
39
|
-
'common/country_tags/zz_world_countries.txt',
|
|
40
|
-
'common/bookmarks/world.txt',
|
|
41
52
|
'common/on_actions/world.txt',
|
|
42
53
|
'common/ai_areas/world.txt',
|
|
43
54
|
'common/ai_strategy_plans/world.txt',
|
|
@@ -45,11 +56,12 @@ export function reconcileWorldOutputs(sources, proposed, operationId, mapRoot, s
|
|
|
45
56
|
localisationRoots.some((root) => name === `${root}/english/world_l_english.yml` ||
|
|
46
57
|
name === `${root}/replace/world_l_english.yml`));
|
|
47
58
|
};
|
|
48
|
-
return
|
|
59
|
+
return planManagedOutputs({
|
|
49
60
|
manifestPath: WORLD_OUTPUT_MANIFEST,
|
|
50
61
|
sources,
|
|
51
62
|
proposed,
|
|
52
63
|
operationId,
|
|
53
64
|
permitsPath,
|
|
65
|
+
...(options.releaseCountryOutputs === true ? { releasable: isWorldCountryOutput } : {}),
|
|
54
66
|
});
|
|
55
67
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { PARTITION_KIND, PARTITION_TERRAIN, partitionFieldsFromBitmaps, } from './partition-fields.js';
|
|
2
|
+
import { PARTITION_PROVINCE_ERROR, PARTITION_PROVINCE_WARNING, } from './partition-tuning.js';
|
|
3
|
+
import { partitionFieldsFromGeography, partitionFieldsWithTerrainLines, } from './partition-geography.js';
|
|
4
|
+
/** Terrain class of each reference terrain code (`referenceTerrainNames` in `world.ts`). */
|
|
5
|
+
const REFERENCE_CLASSES = [
|
|
6
|
+
PARTITION_TERRAIN.plains,
|
|
7
|
+
PARTITION_TERRAIN.forest,
|
|
8
|
+
PARTITION_TERRAIN.desert,
|
|
9
|
+
PARTITION_TERRAIN.mountain,
|
|
10
|
+
PARTITION_TERRAIN.snow,
|
|
11
|
+
PARTITION_TERRAIN.mountain,
|
|
12
|
+
PARTITION_TERRAIN.hills,
|
|
13
|
+
PARTITION_TERRAIN.marsh,
|
|
14
|
+
PARTITION_TERRAIN.jungle,
|
|
15
|
+
];
|
|
16
|
+
/** Value below which a share of the land lies, from a coarse histogram (no sort of 13 million). */
|
|
17
|
+
function landQuantiles(input, shares) {
|
|
18
|
+
const bins = 2048;
|
|
19
|
+
const histogram = new Uint32Array(bins);
|
|
20
|
+
let land = 0;
|
|
21
|
+
for (let p = 0; p < input.kind.length; p++) {
|
|
22
|
+
if (input.kind[p] !== PARTITION_KIND.land)
|
|
23
|
+
continue;
|
|
24
|
+
const value = Math.min(1, Math.max(0, input.elevation[p]));
|
|
25
|
+
histogram[Math.min(bins - 1, Math.floor(value * bins))]++;
|
|
26
|
+
land++;
|
|
27
|
+
}
|
|
28
|
+
return shares.map((share) => {
|
|
29
|
+
let seen = 0;
|
|
30
|
+
for (let bin = 0; bin < bins; bin++) {
|
|
31
|
+
seen += histogram[bin];
|
|
32
|
+
if (seen >= share * land)
|
|
33
|
+
return (bin + 1) / bins;
|
|
34
|
+
}
|
|
35
|
+
return 1;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Partition fields from the generator's own rasters: heights as `heightmap.bmp` will hold them,
|
|
40
|
+
* the drainage rivers, and a per-pixel terrain class (the reference picture's when there is one,
|
|
41
|
+
* otherwise elevation and moisture rules like the province terrain rules).
|
|
42
|
+
*/
|
|
43
|
+
export function surfacePartitionFields(input) {
|
|
44
|
+
const { width, height, kind, elevation, moisture } = input;
|
|
45
|
+
const total = width * height;
|
|
46
|
+
const heights = new Uint8Array(total);
|
|
47
|
+
const terrain = new Uint8Array(total).fill(PARTITION_TERRAIN.water);
|
|
48
|
+
const wet = new Uint8Array(total);
|
|
49
|
+
const [hillLevel = 1, mountainLevel = 1] = input.referenceTerrain === undefined ? landQuantiles(input, [0.72, 0.88]) : [1, 1];
|
|
50
|
+
for (let p = 0; p < total; p++) {
|
|
51
|
+
wet[p] = Math.round(Math.min(1, Math.max(0, moisture[p])) * 255);
|
|
52
|
+
if (kind[p] !== PARTITION_KIND.land) {
|
|
53
|
+
heights[p] = 89;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const value = elevation[p];
|
|
57
|
+
heights[p] =
|
|
58
|
+
input.heights === undefined
|
|
59
|
+
? Math.min(255, Math.max(96, Math.round(96 + value * 110)))
|
|
60
|
+
: Math.max(96, input.heights[p]);
|
|
61
|
+
if (input.referenceTerrain !== undefined) {
|
|
62
|
+
terrain[p] = REFERENCE_CLASSES[input.referenceTerrain[p]] ?? PARTITION_TERRAIN.plains;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const y = Math.floor(p / width);
|
|
66
|
+
const latitude = Math.abs(y / height - 0.5) * 2;
|
|
67
|
+
const m = moisture[p];
|
|
68
|
+
terrain[p] =
|
|
69
|
+
value >= mountainLevel
|
|
70
|
+
? PARTITION_TERRAIN.mountain
|
|
71
|
+
: value >= hillLevel
|
|
72
|
+
? PARTITION_TERRAIN.hills
|
|
73
|
+
: latitude < 0.3 && m > 0.55
|
|
74
|
+
? PARTITION_TERRAIN.jungle
|
|
75
|
+
: latitude > 0.25 && latitude < 0.55 && m < 0.42
|
|
76
|
+
? PARTITION_TERRAIN.desert
|
|
77
|
+
: m > 0.62
|
|
78
|
+
? PARTITION_TERRAIN.marsh
|
|
79
|
+
: m > 0.5
|
|
80
|
+
? PARTITION_TERRAIN.forest
|
|
81
|
+
: PARTITION_TERRAIN.plains;
|
|
82
|
+
}
|
|
83
|
+
const identity = Uint8Array.from({ length: 256 }, (_, index) => index);
|
|
84
|
+
return partitionFieldsFromBitmaps({
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
kind,
|
|
88
|
+
heights,
|
|
89
|
+
rivers: input.riverPixels,
|
|
90
|
+
terrain,
|
|
91
|
+
terrainClasses: identity,
|
|
92
|
+
moisture: wet,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Partition fields of a world built from a geography model: the surface fields read from the
|
|
97
|
+
* synthesised heights, with the model's regions, densities and wall ranges
|
|
98
|
+
* (`partitionFieldsFromGeography`) and the walls, fort lines and crests the synthesis drew
|
|
99
|
+
* (`partitionFieldsWithTerrainLines`).
|
|
100
|
+
*/
|
|
101
|
+
export function geographyPartitionFields(input, geography, terrain) {
|
|
102
|
+
return partitionFieldsWithTerrainLines(partitionFieldsFromGeography(surfacePartitionFields(input), geography), terrain.barrier);
|
|
103
|
+
}
|
|
104
|
+
/** A province-count note for the legacy partition and the final check of the feature-aware one. */
|
|
105
|
+
export function provinceCountNote(provinces, mode) {
|
|
106
|
+
if (provinces <= PARTITION_PROVINCE_WARNING)
|
|
107
|
+
return undefined;
|
|
108
|
+
return {
|
|
109
|
+
code: 'MAP_PARTITION_PROVINCE_COUNT_HIGH',
|
|
110
|
+
severity: 'warning',
|
|
111
|
+
message: `The world has ${provinces} provinces. Vanilla has about 13,400 and the wiki advises at most about 21,000; the text tables (definitions, positions, buildings) near 16 MiB each stop loading beyond that. Raise landProvincePixels and seaProvincePixels to reduce the count`,
|
|
112
|
+
details: {
|
|
113
|
+
provinces,
|
|
114
|
+
warningAbove: PARTITION_PROVINCE_WARNING,
|
|
115
|
+
errorAbove: PARTITION_PROVINCE_ERROR,
|
|
116
|
+
mode,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -10,15 +10,31 @@ function hash(seed, value) {
|
|
|
10
10
|
function circularDelta(x, origin, width) {
|
|
11
11
|
return x - origin - Math.round((x - origin) / width) * width;
|
|
12
12
|
}
|
|
13
|
+
/** Extra land sites a sampling box gets from its mean height and wetness, before rounding. */
|
|
14
|
+
function landDensity(relief, wet) {
|
|
15
|
+
return Math.max(0, relief) * 1.3 + Math.max(0, wet);
|
|
16
|
+
}
|
|
13
17
|
/**
|
|
14
18
|
* Jittered sites use ordinary Euclidean distance, rather than cardinal flood distance.
|
|
15
|
-
* Additional sites in elevated/wet land give those areas a denser, varied partition
|
|
19
|
+
* Additional sites in elevated/wet land give those areas a denser, varied partition; the
|
|
20
|
+
* sampling box grows by the mean of those extras, so they redistribute provinces instead of
|
|
21
|
+
* adding to the requested `landProvincePixels`. Lakes take the sea target.
|
|
16
22
|
* Each occupied sampling box has a site, including thin coasts and small islands.
|
|
17
23
|
*/
|
|
18
24
|
function sites(input, wanted) {
|
|
19
25
|
const { width, height, kind, elevation, moisture, seed } = input;
|
|
20
|
-
const target = wanted ===
|
|
21
|
-
|
|
26
|
+
const target = wanted === 1 ? input.landProvincePixels : input.seaProvincePixels;
|
|
27
|
+
let extras = 0;
|
|
28
|
+
let pixels = 0;
|
|
29
|
+
if (wanted === 1)
|
|
30
|
+
for (let p = 0; p < kind.length; p++) {
|
|
31
|
+
if (kind[p] !== 1)
|
|
32
|
+
continue;
|
|
33
|
+
pixels++;
|
|
34
|
+
extras += Math.min(2, landDensity(elevation[p], moisture[p]));
|
|
35
|
+
}
|
|
36
|
+
const meanExtra = pixels === 0 ? 0 : extras / pixels;
|
|
37
|
+
const cell = Math.max(2, Math.floor(Math.min(Math.sqrt(target * (1 + meanExtra)), width / 20, height / 20)));
|
|
22
38
|
const columns = Math.ceil(width / cell);
|
|
23
39
|
const rows = Math.ceil(height / cell);
|
|
24
40
|
const result = {
|
|
@@ -57,7 +73,7 @@ function sites(input, wanted) {
|
|
|
57
73
|
}
|
|
58
74
|
if (count === 0)
|
|
59
75
|
continue;
|
|
60
|
-
const density = wanted === 1 ?
|
|
76
|
+
const density = wanted === 1 ? landDensity(relief / count, wet / count) : 0;
|
|
61
77
|
const extra = Math.min(2, Math.floor(density + hash(seed, bin + wanted * 7717)));
|
|
62
78
|
const used = new Set();
|
|
63
79
|
for (let slot = 0; slot <= extra; slot++) {
|
|
@@ -152,6 +168,7 @@ function connectedParts(input, labels) {
|
|
|
152
168
|
maxX: [],
|
|
153
169
|
minY: [],
|
|
154
170
|
maxY: [],
|
|
171
|
+
group: [],
|
|
155
172
|
};
|
|
156
173
|
const limitX = Math.max(1, Math.floor(width / 8));
|
|
157
174
|
const limitY = Math.max(1, Math.floor(height / 8));
|
|
@@ -161,6 +178,8 @@ function connectedParts(input, labels) {
|
|
|
161
178
|
const id = parts.parent.length;
|
|
162
179
|
parts.parent.push(id);
|
|
163
180
|
parts.kind.push(kind[start]);
|
|
181
|
+
if (input.group !== undefined)
|
|
182
|
+
parts.group.push(input.group[start]);
|
|
164
183
|
const origin = start % width;
|
|
165
184
|
let minX = 0;
|
|
166
185
|
let maxX = 0;
|
|
@@ -218,13 +237,15 @@ function root(parts, id) {
|
|
|
218
237
|
}
|
|
219
238
|
return r;
|
|
220
239
|
}
|
|
221
|
-
function merge(parts, a, b, width, height) {
|
|
240
|
+
function merge(parts, a, b, width, height, sameGroup = false) {
|
|
222
241
|
a = root(parts, a);
|
|
223
242
|
b = root(parts, b);
|
|
224
243
|
if (a === b)
|
|
225
244
|
return true;
|
|
226
245
|
if (parts.kind[a] !== parts.kind[b])
|
|
227
246
|
return false;
|
|
247
|
+
if (sameGroup && parts.group[a] !== parts.group[b])
|
|
248
|
+
return false;
|
|
228
249
|
const shift = parts.origin[b] - parts.origin[a];
|
|
229
250
|
const minX = Math.min(parts.minX[a], parts.minX[b] + shift);
|
|
230
251
|
const maxX = Math.max(parts.maxX[a], parts.maxX[b] + shift);
|
|
@@ -246,25 +267,39 @@ function merge(parts, a, b, width, height) {
|
|
|
246
267
|
function absorbSlivers(input, parts) {
|
|
247
268
|
const { width, height } = input;
|
|
248
269
|
// Every successful operation removes a whole connected component. The scan is
|
|
249
|
-
// repeated because a previously isolated fragment may become mergeable afterwards.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
// repeated because a previously isolated fragment may become mergeable afterwards. With groups
|
|
271
|
+
// or lines, the first stages merge inside a group and not across a line, and the last stage
|
|
272
|
+
// takes what is left.
|
|
273
|
+
const stages = [];
|
|
274
|
+
if (input.group !== undefined && input.line !== undefined)
|
|
275
|
+
stages.push({ sameGroup: true, avoidLine: true });
|
|
276
|
+
if (input.line !== undefined)
|
|
277
|
+
stages.push({ sameGroup: false, avoidLine: true });
|
|
278
|
+
if (input.group !== undefined)
|
|
279
|
+
stages.push({ sameGroup: true, avoidLine: false });
|
|
280
|
+
stages.push({ sameGroup: false, avoidLine: false });
|
|
281
|
+
for (const { sameGroup, avoidLine } of stages) {
|
|
282
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
283
|
+
let changed = false;
|
|
284
|
+
for (let p = 0; p < parts.pixel.length; p++) {
|
|
285
|
+
const x = p % width;
|
|
286
|
+
const adjacent = [x + 1 < width ? p + 1 : p + 1 - width, p + width];
|
|
287
|
+
for (const q of adjacent) {
|
|
288
|
+
if (q >= parts.pixel.length)
|
|
289
|
+
continue;
|
|
290
|
+
if (avoidLine && (input.line[p] !== 0 || input.line[q] !== 0))
|
|
291
|
+
continue;
|
|
292
|
+
const a = root(parts, parts.pixel[p]);
|
|
293
|
+
const b = root(parts, parts.pixel[q]);
|
|
294
|
+
if (a === b || (parts.size[a] >= 20 && parts.size[b] >= 20))
|
|
295
|
+
continue;
|
|
296
|
+
if (merge(parts, a, b, width, height, sameGroup))
|
|
297
|
+
changed = true;
|
|
298
|
+
}
|
|
264
299
|
}
|
|
300
|
+
if (!changed)
|
|
301
|
+
break;
|
|
265
302
|
}
|
|
266
|
-
if (!changed)
|
|
267
|
-
break;
|
|
268
303
|
}
|
|
269
304
|
}
|
|
270
305
|
/** Removing a pixel may not cut its donor; verify the donor's local four-way paths. */
|
|
@@ -352,6 +387,14 @@ function rebalanceSlivers(input, parts) {
|
|
|
352
387
|
}
|
|
353
388
|
function repairCrossings(input, parts) {
|
|
354
389
|
const { width, height, kind } = input;
|
|
390
|
+
const repairStages = [];
|
|
391
|
+
if (input.group !== undefined && input.line !== undefined)
|
|
392
|
+
repairStages.push({ sameGroup: true, avoidLine: true });
|
|
393
|
+
if (input.line !== undefined)
|
|
394
|
+
repairStages.push({ sameGroup: false, avoidLine: true });
|
|
395
|
+
if (input.group !== undefined)
|
|
396
|
+
repairStages.push({ sameGroup: true, avoidLine: false });
|
|
397
|
+
repairStages.push({ sameGroup: false, avoidLine: false });
|
|
355
398
|
for (let pass = 0; pass < 12; pass++) {
|
|
356
399
|
let changed = false;
|
|
357
400
|
for (let y = 0; y + 1 < height; y++) {
|
|
@@ -369,22 +412,32 @@ function repairCrossings(input, parts) {
|
|
|
369
412
|
[2, 3],
|
|
370
413
|
];
|
|
371
414
|
let repaired = false;
|
|
372
|
-
// Whole-cell unions cannot introduce crossings or disconnected provinces.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
415
|
+
// Whole-cell unions cannot introduce crossings or disconnected provinces. With groups or
|
|
416
|
+
// lines, the repairs inside one group and not across a line come first.
|
|
417
|
+
for (const { sameGroup, avoidLine } of repairStages) {
|
|
418
|
+
for (const [i, j] of edges) {
|
|
419
|
+
if (avoidLine && (input.line[corners[i]] !== 0 || input.line[corners[j]] !== 0))
|
|
420
|
+
continue;
|
|
421
|
+
if (merge(parts, ids[i], ids[j], width, height, sameGroup)) {
|
|
422
|
+
repaired = true;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
377
425
|
}
|
|
378
|
-
|
|
379
|
-
|
|
426
|
+
if (repaired)
|
|
427
|
+
break;
|
|
380
428
|
for (const [i, j] of edges.flatMap(([i, j]) => [
|
|
381
429
|
[i, j],
|
|
382
430
|
[j, i],
|
|
383
431
|
])) {
|
|
384
432
|
const p = corners[i];
|
|
385
433
|
const q = corners[j];
|
|
434
|
+
if (avoidLine && (input.line[p] !== 0 || input.line[q] !== 0))
|
|
435
|
+
continue;
|
|
386
436
|
if (kind[p] !== kind[q] || !removable(parts, p, width, height))
|
|
387
437
|
continue;
|
|
438
|
+
if (sameGroup &&
|
|
439
|
+
parts.group[root(parts, ids[i])] !== parts.group[root(parts, ids[j])])
|
|
440
|
+
continue;
|
|
388
441
|
const target = ids[j];
|
|
389
442
|
const dx = (p % width) - parts.origin[target];
|
|
390
443
|
const py = Math.floor(p / width);
|
|
@@ -404,6 +457,8 @@ function repairCrossings(input, parts) {
|
|
|
404
457
|
repaired = true;
|
|
405
458
|
break;
|
|
406
459
|
}
|
|
460
|
+
if (repaired)
|
|
461
|
+
break;
|
|
407
462
|
}
|
|
408
463
|
if (!repaired)
|
|
409
464
|
throw new ServiceError('MAP_WORLD_UNREPRESENTABLE_TOPOLOGY', 'The exact land/sea/lake mask cannot resolve a four-province corner while preserving connected provinces and size limits', { x, y });
|
|
@@ -416,33 +471,13 @@ function repairCrossings(input, parts) {
|
|
|
416
471
|
throw new ServiceError('MAP_WORLD_UNREPRESENTABLE_TOPOLOGY', 'Province corner repair did not converge');
|
|
417
472
|
}
|
|
418
473
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
474
|
+
* Refuse a mask that needs more provinces than the engine tolerates before any site is built:
|
|
475
|
+
* a singleton component of one kind necessarily needs its own province, which is an inexpensive
|
|
476
|
+
* lower bound for a hostile fragmented mask.
|
|
422
477
|
*/
|
|
423
|
-
export function
|
|
424
|
-
const { width,
|
|
425
|
-
|
|
426
|
-
invalid('Province dimensions must be integers of at least 16 pixels');
|
|
427
|
-
const total = width * height;
|
|
428
|
-
if (!Number.isSafeInteger(total) || total > 13_238_272)
|
|
429
|
-
invalid('Province dimensions exceed the 13,238,272-pixel map limit');
|
|
430
|
-
if (!Number.isSafeInteger(seed))
|
|
431
|
-
invalid('The province seed must be a safe integer');
|
|
432
|
-
if (!(kind instanceof Uint8Array) ||
|
|
433
|
-
!(elevation instanceof Float32Array) ||
|
|
434
|
-
!(moisture instanceof Float32Array))
|
|
435
|
-
invalid('Province fields must use the specified typed arrays');
|
|
436
|
-
if (kind.length !== total || elevation.length !== total || moisture.length !== total)
|
|
437
|
-
invalid('Province fields must match the map dimensions');
|
|
438
|
-
if (![input.landProvincePixels, input.seaProvincePixels].every((v) => Number.isFinite(v) && v >= 20))
|
|
439
|
-
invalid('Province target areas must be finite and at least 20 pixels');
|
|
440
|
-
for (let p = 0; p < total; p++) {
|
|
441
|
-
if (kind[p] > 2 || !Number.isFinite(elevation[p]) || !Number.isFinite(moisture[p]))
|
|
442
|
-
invalid('Province fields contain an unsupported kind or non-finite terrain value');
|
|
443
|
-
}
|
|
444
|
-
// A singleton kind component necessarily needs its own province. Count that
|
|
445
|
-
// inexpensive lower bound before building sites for a hostile fragmented mask.
|
|
478
|
+
export function assertProvinceMaskFeasible(input) {
|
|
479
|
+
const { width, kind } = input;
|
|
480
|
+
const total = width * input.height;
|
|
446
481
|
let isolated = 0;
|
|
447
482
|
for (let p = 0; p < total; p++) {
|
|
448
483
|
const x = p % width;
|
|
@@ -456,7 +491,18 @@ export function generateWorldProvinces(input) {
|
|
|
456
491
|
}
|
|
457
492
|
if (isolated > 65_000)
|
|
458
493
|
throw new ServiceError('MAP_WORLD_TOO_MANY_PROVINCES', 'The kind mask requires more than 65,000 isolated provinces', { isolated });
|
|
459
|
-
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Turn one label per pixel into provinces: split disconnected and over-wide parts, absorb slivers,
|
|
497
|
+
* keep every province at least 20 pixels where the mask allows, repair four-province corners and
|
|
498
|
+
* number the provinces by their first pixel. Both partitions finish through this function, so the
|
|
499
|
+
* engine rules (connected provinces, the bounding box limit, no X crossing, the 65,000 cap) hold
|
|
500
|
+
* whichever one made the labels.
|
|
501
|
+
*/
|
|
502
|
+
export function assembleWorldProvinces(input, labels) {
|
|
503
|
+
const { width, height, kind } = input;
|
|
504
|
+
const total = width * height;
|
|
505
|
+
const parts = connectedParts(input, labels);
|
|
460
506
|
absorbSlivers(input, parts);
|
|
461
507
|
rebalanceSlivers(input, parts);
|
|
462
508
|
repairCrossings(input, parts);
|
|
@@ -522,3 +568,32 @@ export function generateWorldProvinces(input) {
|
|
|
522
568
|
}
|
|
523
569
|
return result;
|
|
524
570
|
}
|
|
571
|
+
/**
|
|
572
|
+
* Deterministic province geometry with periodic adjacency, but separate province IDs at the seam.
|
|
573
|
+
* Native province bounding boxes use bitmap coordinates, not circular spans. Work is proportional to pixels
|
|
574
|
+
* with bounded local candidate searches; no retained per-pixel objects or global site scans.
|
|
575
|
+
*/
|
|
576
|
+
export function generateWorldProvinces(input) {
|
|
577
|
+
const { width, height, kind, elevation, moisture, seed } = input;
|
|
578
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16)
|
|
579
|
+
invalid('Province dimensions must be integers of at least 16 pixels');
|
|
580
|
+
const total = width * height;
|
|
581
|
+
if (!Number.isSafeInteger(total) || total > 13_238_272)
|
|
582
|
+
invalid('Province dimensions exceed the 13,238,272-pixel map limit');
|
|
583
|
+
if (!Number.isSafeInteger(seed))
|
|
584
|
+
invalid('The province seed must be a safe integer');
|
|
585
|
+
if (!(kind instanceof Uint8Array) ||
|
|
586
|
+
!(elevation instanceof Float32Array) ||
|
|
587
|
+
!(moisture instanceof Float32Array))
|
|
588
|
+
invalid('Province fields must use the specified typed arrays');
|
|
589
|
+
if (kind.length !== total || elevation.length !== total || moisture.length !== total)
|
|
590
|
+
invalid('Province fields must match the map dimensions');
|
|
591
|
+
if (![input.landProvincePixels, input.seaProvincePixels].every((v) => Number.isFinite(v) && v >= 20))
|
|
592
|
+
invalid('Province target areas must be finite and at least 20 pixels');
|
|
593
|
+
for (let p = 0; p < total; p++) {
|
|
594
|
+
if (kind[p] > 2 || !Number.isFinite(elevation[p]) || !Number.isFinite(moisture[p]))
|
|
595
|
+
invalid('Province fields contain an unsupported kind or non-finite terrain value');
|
|
596
|
+
}
|
|
597
|
+
assertProvinceMaskFeasible(input);
|
|
598
|
+
return assembleWorldProvinces(input, nearestPartition(input));
|
|
599
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-owned countries in a generated world (ADR 0069): realm points and polygons resolved to
|
|
3
|
+
* provinces, and the rule for land that no realm reached.
|
|
4
|
+
*/
|
|
5
|
+
import { ServiceError } from '../core/result.js';
|
|
6
|
+
function inside(x, y, polygon) {
|
|
7
|
+
let result = false;
|
|
8
|
+
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
|
|
9
|
+
const a = polygon[index], b = polygon[previous];
|
|
10
|
+
if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x)
|
|
11
|
+
result = !result;
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
/** Land provinces with more than half their pixels inside any of the polygons. */
|
|
16
|
+
function coveredProvinces(polygons, raster) {
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
const pixels = new Set();
|
|
19
|
+
for (const polygon of polygons) {
|
|
20
|
+
const xs = polygon.map(({ x }) => x), ys = polygon.map(({ y }) => y);
|
|
21
|
+
const left = Math.max(0, Math.floor(Math.min(...xs)));
|
|
22
|
+
const right = Math.min(raster.width - 1, Math.ceil(Math.max(...xs)));
|
|
23
|
+
const top = Math.max(0, Math.floor(Math.min(...ys)));
|
|
24
|
+
const bottom = Math.min(raster.height - 1, Math.ceil(Math.max(...ys)));
|
|
25
|
+
for (let y = top; y <= bottom; y += 1)
|
|
26
|
+
for (let x = left; x <= right; x += 1) {
|
|
27
|
+
const pixel = y * raster.width + x;
|
|
28
|
+
if (pixels.has(pixel) || !inside(x + 0.5, y + 0.5, polygon))
|
|
29
|
+
continue;
|
|
30
|
+
pixels.add(pixel);
|
|
31
|
+
const province = raster.pixelProvince[pixel];
|
|
32
|
+
counts.set(province, (counts.get(province) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const covered = new Set();
|
|
36
|
+
for (const [province, count] of [...counts].sort((left, right) => left[0] - right[0]))
|
|
37
|
+
if (raster.isLand(province) && count * 2 > raster.provinceSize[province])
|
|
38
|
+
covered.add(province);
|
|
39
|
+
return covered;
|
|
40
|
+
}
|
|
41
|
+
function pointProvince(tag, role, point, raster) {
|
|
42
|
+
const x = Math.floor(point.x), y = Math.floor(point.y);
|
|
43
|
+
if (x < 0 || y < 0 || x >= raster.width || y >= raster.height)
|
|
44
|
+
throw new ServiceError('MAP_WORLD_REALM_POINT_OUTSIDE', `The ${role} of ${tag} lies outside the ${raster.width}x${raster.height} world`, { tag, role, point });
|
|
45
|
+
const province = raster.pixelProvince[y * raster.width + x];
|
|
46
|
+
if (!raster.isLand(province))
|
|
47
|
+
throw new ServiceError('MAP_WORLD_REALM_POINT_WATER', `The ${role} of ${tag} lies in water`, {
|
|
48
|
+
tag,
|
|
49
|
+
role,
|
|
50
|
+
point,
|
|
51
|
+
});
|
|
52
|
+
return province;
|
|
53
|
+
}
|
|
54
|
+
export function resolveRealmRules(realms, raster) {
|
|
55
|
+
const unowned = realms.unowned === undefined ? new Set() : coveredProvinces(realms.unowned, raster);
|
|
56
|
+
const excluded = realms.list.map((realm) => realm.exclude === undefined ? new Set() : coveredProvinces(realm.exclude, raster));
|
|
57
|
+
const forced = new Map();
|
|
58
|
+
const claimedBy = (province) => realms.list[forced.get(province)].tag;
|
|
59
|
+
const conflict = (tag, role, other, details) => new ServiceError('MAP_WORLD_REALM_POINT_CONFLICT', `The ${role} of ${tag} lies in land ${other}`, { tag, role, ...details });
|
|
60
|
+
const capitals = [];
|
|
61
|
+
// Points first, so a claim that covers another realm's point names that point.
|
|
62
|
+
for (const [index, realm] of realms.list.entries()) {
|
|
63
|
+
for (const [role, point] of [
|
|
64
|
+
['capital', realm.capital],
|
|
65
|
+
...(realm.seeds ?? []).map((seed) => ['seed', seed]),
|
|
66
|
+
]) {
|
|
67
|
+
const province = pointProvince(realm.tag, role, point, raster);
|
|
68
|
+
if (unowned.has(province))
|
|
69
|
+
throw conflict(realm.tag, role, 'marked unowned', { point, province });
|
|
70
|
+
if (excluded[index].has(province))
|
|
71
|
+
throw conflict(realm.tag, role, 'its own exclusion covers', { point, province });
|
|
72
|
+
const holder = forced.get(province);
|
|
73
|
+
if (holder !== undefined && holder !== index)
|
|
74
|
+
throw conflict(realm.tag, role, `that a point of ${claimedBy(province)} names`, {
|
|
75
|
+
point,
|
|
76
|
+
province,
|
|
77
|
+
otherTag: claimedBy(province),
|
|
78
|
+
});
|
|
79
|
+
forced.set(province, index);
|
|
80
|
+
if (role === 'capital')
|
|
81
|
+
capitals.push(province);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [index, realm] of realms.list.entries()) {
|
|
85
|
+
if (realm.claim === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
for (const province of coveredProvinces(realm.claim, raster)) {
|
|
88
|
+
const holder = forced.get(province);
|
|
89
|
+
if (unowned.has(province) || excluded[index].has(province))
|
|
90
|
+
throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that is ${unowned.has(province) ? 'marked unowned' : 'in its own exclusion'}`, { tag: realm.tag, province });
|
|
91
|
+
if (holder !== undefined && holder !== index)
|
|
92
|
+
throw new ServiceError('MAP_WORLD_REALM_CLAIM_OVERLAP', `A claim of ${realm.tag} covers land that ${claimedBy(province)} claims or names`, { tag: realm.tag, otherTag: claimedBy(province), province });
|
|
93
|
+
forced.set(province, index);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { capitals, forced, excluded, unowned };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Land growth did not reach: a landmass that a realm already holds goes to the nearest realm that
|
|
100
|
+
* may own it; a landmass without any realm follows the island rule.
|
|
101
|
+
*/
|
|
102
|
+
export function assignUnreachedRealmLand(input) {
|
|
103
|
+
const held = new Set();
|
|
104
|
+
for (const id of input.owner.keys())
|
|
105
|
+
held.add(input.part(id));
|
|
106
|
+
const distance = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
|
|
107
|
+
const nearest = (point, allowed) => {
|
|
108
|
+
let best = -1;
|
|
109
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
110
|
+
for (const [realm, capital] of input.capitals.entries()) {
|
|
111
|
+
if (!allowed(realm))
|
|
112
|
+
continue;
|
|
113
|
+
const next = distance(point, input.center(capital));
|
|
114
|
+
if (next < bestDistance) {
|
|
115
|
+
best = realm;
|
|
116
|
+
bestDistance = next;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return best;
|
|
120
|
+
};
|
|
121
|
+
const pending = input.landIds.filter((id) => !input.owner.has(id) && !input.unowned.has(id));
|
|
122
|
+
const partOwner = new Map();
|
|
123
|
+
if (input.islands === 'one-owner') {
|
|
124
|
+
const members = new Map();
|
|
125
|
+
for (const id of pending)
|
|
126
|
+
if (!held.has(input.part(id))) {
|
|
127
|
+
const list = members.get(input.part(id)) ?? [];
|
|
128
|
+
list.push(id);
|
|
129
|
+
members.set(input.part(id), list);
|
|
130
|
+
}
|
|
131
|
+
for (const [part, ids] of members) {
|
|
132
|
+
const mean = ids.reduce((sum, id) => ({ x: sum.x + input.center(id).x, y: sum.y + input.center(id).y }), { x: 0, y: 0 });
|
|
133
|
+
partOwner.set(part, nearest({ x: mean.x / ids.length, y: mean.y / ids.length }, () => true));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const id of pending) {
|
|
137
|
+
const allowed = (realm) => !input.excluded[realm].has(id);
|
|
138
|
+
const island = !held.has(input.part(id));
|
|
139
|
+
if (island && input.islands === 'unowned') {
|
|
140
|
+
input.unowned.add(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const preferred = island ? partOwner.get(input.part(id)) : undefined;
|
|
144
|
+
const realm = preferred !== undefined && preferred >= 0 && allowed(preferred)
|
|
145
|
+
? preferred
|
|
146
|
+
: nearest(input.center(id), allowed);
|
|
147
|
+
if (realm < 0)
|
|
148
|
+
input.unowned.add(id);
|
|
149
|
+
else
|
|
150
|
+
input.owner.set(id, realm);
|
|
151
|
+
}
|
|
152
|
+
}
|