hoi4-agent-tools 4.5.0 → 4.6.1
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 +21 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
- package/dist/hoi4_agent_tools/map/derived.js +16 -3
- package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
- package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
- package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
- package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
- package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
- package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
- package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
- package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
- package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
- package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
- package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
- package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
- package/dist/hoi4_agent_tools/map/operations.js +109 -32
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +107 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +52 -0
- package/dist/hoi4_agent_tools/map/service.js +78 -16
- package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
- package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
- package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
- package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
- package/dist/hoi4_agent_tools/map/territory.js +1108 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +78 -0
- package/dist/hoi4_agent_tools/map/validation.js +4 -0
- package/dist/hoi4_agent_tools/map/world.js +301 -107
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +3 -0
- package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
- package/dist/hoi4_agent_tools/schemas/map.js +206 -21
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/map.md +100 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- 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 +13 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- 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 +1355 -736
- 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 +1 -1
- 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 +1 -1
- 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,132 @@
|
|
|
1
|
+
import { scanAreaStrip } from '../geography/index.js';
|
|
2
|
+
import { LAND, SEA, factGrid } from './facts.js';
|
|
3
|
+
/** Fertility of a biome kind when the model gives none. */
|
|
4
|
+
export const BIOME_FERTILITY = {
|
|
5
|
+
farmland: 0.9,
|
|
6
|
+
plains: 0.7,
|
|
7
|
+
steppe: 0.5,
|
|
8
|
+
forest: 0.5,
|
|
9
|
+
hills: 0.4,
|
|
10
|
+
jungle: 0.35,
|
|
11
|
+
marsh: 0.25,
|
|
12
|
+
tundra: 0.15,
|
|
13
|
+
desert: 0.1,
|
|
14
|
+
ice: 0.02,
|
|
15
|
+
};
|
|
16
|
+
/** Fertility of a province terrain when nothing else is known. */
|
|
17
|
+
export const TERRAIN_FERTILITY = {
|
|
18
|
+
plains: 0.7,
|
|
19
|
+
urban: 0.6,
|
|
20
|
+
forest: 0.55,
|
|
21
|
+
hills: 0.4,
|
|
22
|
+
jungle: 0.35,
|
|
23
|
+
marsh: 0.25,
|
|
24
|
+
desert: 0.12,
|
|
25
|
+
mountain: 0.15,
|
|
26
|
+
lakes: 0,
|
|
27
|
+
ocean: 0,
|
|
28
|
+
};
|
|
29
|
+
export function computeMetrics(input, view) {
|
|
30
|
+
const { width, height, geography } = input;
|
|
31
|
+
const count = view.count;
|
|
32
|
+
const kind = input.provinces.kind;
|
|
33
|
+
const heightSum = new Float64Array(count);
|
|
34
|
+
const size = new Float64Array(count);
|
|
35
|
+
const riverPixels = new Uint32Array(count);
|
|
36
|
+
const riverWidth = new Uint8Array(count);
|
|
37
|
+
const mouthWidth = new Uint8Array(count);
|
|
38
|
+
const valleySum = new Float64Array(count);
|
|
39
|
+
const mountainSum = new Float64Array(count);
|
|
40
|
+
const valley = input.fields?.valley;
|
|
41
|
+
const mountain = input.fields?.mountain;
|
|
42
|
+
const rivers = input.riverBitmap;
|
|
43
|
+
for (let y = 0; y < height; y += 1) {
|
|
44
|
+
const row = y * width;
|
|
45
|
+
for (let x = 0; x < width; x += 1) {
|
|
46
|
+
const p = row + x;
|
|
47
|
+
const id = view.pixel[p];
|
|
48
|
+
size[id] += 1;
|
|
49
|
+
heightSum[id] += input.heights[p];
|
|
50
|
+
if (valley !== undefined)
|
|
51
|
+
valleySum[id] += valley.data[(y >> 1) * valley.width + (x >> 1)];
|
|
52
|
+
if (mountain !== undefined)
|
|
53
|
+
mountainSum[id] += mountain.data[(y >> 1) * mountain.width + (x >> 1)];
|
|
54
|
+
const river = rivers[p];
|
|
55
|
+
if (river > 11)
|
|
56
|
+
continue;
|
|
57
|
+
riverPixels[id] += 1;
|
|
58
|
+
const index = river < 3 ? 3 : river;
|
|
59
|
+
if (index > riverWidth[id])
|
|
60
|
+
riverWidth[id] = index;
|
|
61
|
+
const touching = (x > 0 && kind[view.pixel[p - 1]] === SEA) ||
|
|
62
|
+
(x + 1 < width && kind[view.pixel[p + 1]] === SEA) ||
|
|
63
|
+
(y > 0 && kind[view.pixel[p - width]] === SEA) ||
|
|
64
|
+
(y + 1 < height && kind[view.pixel[p + width]] === SEA);
|
|
65
|
+
if (touching && index > mouthWidth[id])
|
|
66
|
+
mouthWidth[id] = index;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const meanHeight = new Float32Array(count);
|
|
70
|
+
const valleyMean = new Float32Array(count);
|
|
71
|
+
const mountainMean = new Float32Array(count);
|
|
72
|
+
for (let id = 0; id < count; id += 1) {
|
|
73
|
+
const n = Math.max(1, size[id]);
|
|
74
|
+
meanHeight[id] = heightSum[id] / n;
|
|
75
|
+
valleyMean[id] = valleySum[id] / n / 255;
|
|
76
|
+
mountainMean[id] = mountainSum[id] / n / 255;
|
|
77
|
+
}
|
|
78
|
+
// Fertility the model states: land defaults, then biomes from low to high priority.
|
|
79
|
+
const grid = factGrid(width, height);
|
|
80
|
+
const cells = new Float32Array(grid.width * grid.height).fill(Number.NaN);
|
|
81
|
+
const whole = { y0: 0, y1: grid.height };
|
|
82
|
+
const paint = (area, value) => scanAreaStrip(area, grid, whole, (y, x0, x1) => {
|
|
83
|
+
cells.fill(value, y * grid.width + x0, y * grid.width + x1);
|
|
84
|
+
});
|
|
85
|
+
for (const land of geography.lands)
|
|
86
|
+
if (land.fertility !== undefined)
|
|
87
|
+
paint(land.area, land.fertility);
|
|
88
|
+
for (const biome of geography.biomes)
|
|
89
|
+
paint(biome.area, biome.fertility ?? BIOME_FERTILITY[biome.biome]);
|
|
90
|
+
const samples = new Uint32Array(count);
|
|
91
|
+
const valued = new Uint32Array(count);
|
|
92
|
+
const total = new Float64Array(count);
|
|
93
|
+
const stride = 3;
|
|
94
|
+
for (let y = 0; y < height; y += stride) {
|
|
95
|
+
const gy = Math.min(grid.height - 1, Math.floor(((y + 0.5) * grid.height) / height));
|
|
96
|
+
for (let x = 0; x < width; x += stride) {
|
|
97
|
+
const id = view.pixel[y * width + x];
|
|
98
|
+
if (kind[id] !== LAND)
|
|
99
|
+
continue;
|
|
100
|
+
samples[id] += 1;
|
|
101
|
+
const gx = Math.min(grid.width - 1, Math.floor(((x + 0.5) * grid.width) / width));
|
|
102
|
+
const value = cells[gy * grid.width + gx];
|
|
103
|
+
if (Number.isNaN(value))
|
|
104
|
+
continue;
|
|
105
|
+
valued[id] += 1;
|
|
106
|
+
total[id] += value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const stated = new Float32Array(count).fill(Number.NaN);
|
|
110
|
+
for (let id = 0; id < count; id += 1)
|
|
111
|
+
if (kind[id] === LAND && samples[id] > 0 && valued[id] * 2 >= samples[id])
|
|
112
|
+
stated[id] = total[id] / valued[id];
|
|
113
|
+
return {
|
|
114
|
+
meanHeight,
|
|
115
|
+
riverPixels,
|
|
116
|
+
riverWidth,
|
|
117
|
+
mouthWidth,
|
|
118
|
+
valley: valleyMean,
|
|
119
|
+
mountain: mountainMean,
|
|
120
|
+
stated,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Fertility of a province: the model's statement, else the terrain's, plus valley and slope. */
|
|
124
|
+
export function fertilityOf(metrics, province, terrain) {
|
|
125
|
+
const stated = metrics.stated[province];
|
|
126
|
+
let value = Number.isNaN(stated) ? (TERRAIN_FERTILITY[terrain] ?? 0.4) : stated;
|
|
127
|
+
value += 0.2 * metrics.valley[province];
|
|
128
|
+
const high = metrics.meanHeight[province] - 140;
|
|
129
|
+
if (high > 0)
|
|
130
|
+
value -= Math.min(0.3, high / 300);
|
|
131
|
+
return Math.max(0, Math.min(1, value));
|
|
132
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names for states, strategic regions, victory points and lakes from the geography model
|
|
3
|
+
* (ADR 0063). A feature is named by what the model puts there: a site, a lake shore, a river
|
|
4
|
+
* valley, a range, a bay, a region, a land or a sea. Names are made unique with compass
|
|
5
|
+
* qualifiers and, as a last resort, numerals. A feature the model gives no name falls back to the
|
|
6
|
+
* generic "District N" or "Region N" and is counted, so a world with many placeholders says so.
|
|
7
|
+
*/
|
|
8
|
+
import { nearestOnPolyline } from '../geography/index.js';
|
|
9
|
+
import { LAND, SEA, centerOf } from './facts.js';
|
|
10
|
+
import { SITE_WEIGHT, landProvinceNear } from './sites.js';
|
|
11
|
+
const ROMAN = ['', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
|
|
12
|
+
function roman(position) {
|
|
13
|
+
return position < ROMAN.length ? ROMAN[position] : String(position + 1);
|
|
14
|
+
}
|
|
15
|
+
/** The compass word for an offset from a group's centre; screen y grows southwards. */
|
|
16
|
+
function compass(dx, dy, near) {
|
|
17
|
+
if (near)
|
|
18
|
+
return 'Central';
|
|
19
|
+
const ax = Math.abs(dx);
|
|
20
|
+
const ay = Math.abs(dy);
|
|
21
|
+
if (ay * 2.414 < ax)
|
|
22
|
+
return dx > 0 ? 'Eastern' : 'Western';
|
|
23
|
+
if (ax * 2.414 < ay)
|
|
24
|
+
return dy > 0 ? 'Southern' : 'Northern';
|
|
25
|
+
return `${dy > 0 ? 'South' : 'North'}${dx > 0 ? 'eastern' : 'western'}`;
|
|
26
|
+
}
|
|
27
|
+
/** A name without its leading article, for composing "<name> Shore" and the like. */
|
|
28
|
+
function bare(name) {
|
|
29
|
+
return name.replace(/^(the|a|an)\s+/iu, '');
|
|
30
|
+
}
|
|
31
|
+
const SETTLEMENT_KINDS = new Set(['capital', 'city', 'port', 'fortress']);
|
|
32
|
+
/**
|
|
33
|
+
* Make a list of base names unique: names shared by several items get a compass qualifier by
|
|
34
|
+
* position, then numerals for items still tied. `order` fixes the numeral order.
|
|
35
|
+
*/
|
|
36
|
+
export function uniqueNames(bases) {
|
|
37
|
+
const out = new Map();
|
|
38
|
+
const groups = new Map();
|
|
39
|
+
for (const { key, name, at } of bases) {
|
|
40
|
+
const list = groups.get(name) ?? [];
|
|
41
|
+
list.push({ key, at });
|
|
42
|
+
groups.set(name, list);
|
|
43
|
+
}
|
|
44
|
+
const taken = new Set();
|
|
45
|
+
for (const [name, members] of [...groups].sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) {
|
|
46
|
+
if (members.length === 1) {
|
|
47
|
+
out.set(members[0].key, name);
|
|
48
|
+
taken.add(name);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
let cx = 0;
|
|
52
|
+
let cy = 0;
|
|
53
|
+
for (const { at } of members) {
|
|
54
|
+
cx += at.x;
|
|
55
|
+
cy += at.y;
|
|
56
|
+
}
|
|
57
|
+
cx /= members.length;
|
|
58
|
+
cy /= members.length;
|
|
59
|
+
let reach = 0;
|
|
60
|
+
for (const { at } of members)
|
|
61
|
+
reach = Math.max(reach, Math.abs(at.x - cx) + Math.abs(at.y - cy));
|
|
62
|
+
const labelled = members.map(({ key, at }) => {
|
|
63
|
+
const dx = at.x - cx;
|
|
64
|
+
const dy = at.y - cy;
|
|
65
|
+
const near = members.length > 2 && Math.abs(dx) + Math.abs(dy) < reach * 0.3;
|
|
66
|
+
let label;
|
|
67
|
+
if (members.length === 2) {
|
|
68
|
+
const spreadX = Math.abs(members[0].at.x - members[1].at.x);
|
|
69
|
+
const spreadY = Math.abs(members[0].at.y - members[1].at.y);
|
|
70
|
+
label =
|
|
71
|
+
spreadX >= spreadY ? (dx > 0 ? 'Eastern' : 'Western') : dy > 0 ? 'Southern' : 'Northern';
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
label = compass(dx, dy, near);
|
|
75
|
+
return { key, label };
|
|
76
|
+
});
|
|
77
|
+
const byLabel = new Map();
|
|
78
|
+
for (const { key, label } of labelled) {
|
|
79
|
+
const list = byLabel.get(label) ?? [];
|
|
80
|
+
list.push(key);
|
|
81
|
+
byLabel.set(label, list);
|
|
82
|
+
}
|
|
83
|
+
for (const [label, keys] of [...byLabel].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
84
|
+
keys.sort((a, b) => a - b);
|
|
85
|
+
for (const [position, key] of keys.entries()) {
|
|
86
|
+
const text = `${label} ${name}${position === 0 ? '' : ` ${roman(position)}`}`;
|
|
87
|
+
out.set(key, text);
|
|
88
|
+
taken.add(text);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function placeNames(base) {
|
|
95
|
+
const { geography, width, height } = base.input;
|
|
96
|
+
const out = new Map();
|
|
97
|
+
const reach = Math.max(8, Math.round(width * 0.02));
|
|
98
|
+
for (const place of geography.places) {
|
|
99
|
+
if (place.name === undefined)
|
|
100
|
+
continue;
|
|
101
|
+
const found = landProvinceNear(base, place.at.x * width, place.at.y * height, reach);
|
|
102
|
+
if (found === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
const list = out.get(found.province) ?? [];
|
|
105
|
+
list.push(place.name);
|
|
106
|
+
out.set(found.province, list);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** The named river that runs through most of a province's pixels, as an index into `rivers`. */
|
|
111
|
+
function riverOfProvince(base, geography) {
|
|
112
|
+
const out = new Int32Array(base.view.count).fill(-1);
|
|
113
|
+
const paths = base.input.riverPaths;
|
|
114
|
+
if (paths === undefined)
|
|
115
|
+
return out;
|
|
116
|
+
const named = new Map(geography.rivers.map((river, index) => [river.id, index]));
|
|
117
|
+
const votes = new Map();
|
|
118
|
+
const pixel = base.view.pixel;
|
|
119
|
+
for (const path of paths) {
|
|
120
|
+
const index = named.get(path.id);
|
|
121
|
+
if (index === undefined || geography.rivers[index].name === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
for (let at = 0; at < path.pixels.length; at += 2) {
|
|
124
|
+
const province = pixel[path.pixels[at]];
|
|
125
|
+
const key = province * 4_096 + index;
|
|
126
|
+
votes.set(key, (votes.get(key) ?? 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const best = new Int32Array(base.view.count);
|
|
130
|
+
for (const [key, count] of [...votes].sort((a, b) => a[0] - b[0])) {
|
|
131
|
+
const province = Math.floor(key / 4_096);
|
|
132
|
+
if (count >= 6 && count > best[province]) {
|
|
133
|
+
best[province] = count;
|
|
134
|
+
out[province] = key - province * 4_096;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** The nearest range with a name within one and a half half-widths of a province's centre. */
|
|
140
|
+
function rangeOfProvince(base, metrics) {
|
|
141
|
+
const { geography, width, height, provinces } = base.input;
|
|
142
|
+
const out = new Int32Array(base.view.count).fill(-1);
|
|
143
|
+
for (let id = 0; id < base.view.count; id += 1) {
|
|
144
|
+
if (provinces.kind[id] !== LAND || metrics.mountain[id] < 0.2)
|
|
145
|
+
continue;
|
|
146
|
+
const centre = centerOf(provinces, id);
|
|
147
|
+
let best = Number.POSITIVE_INFINITY;
|
|
148
|
+
for (const [index, range] of geography.ranges.entries()) {
|
|
149
|
+
if (range.name === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
const near = nearestOnPolyline(range.path, geography.frame, centre.x / width, centre.y / height);
|
|
152
|
+
const ratio = near.distance / Math.max(near.halfWidth, 0.002);
|
|
153
|
+
if (ratio <= 1.6 && ratio < best) {
|
|
154
|
+
best = ratio;
|
|
155
|
+
out[id] = index;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
function mostCommon(values) {
|
|
162
|
+
const counts = new Map();
|
|
163
|
+
for (const value of values)
|
|
164
|
+
if (value !== undefined)
|
|
165
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
166
|
+
let best;
|
|
167
|
+
let bestCount = 0;
|
|
168
|
+
for (const [value, count] of [...counts].sort((a, b) => (a[0] < b[0] ? -1 : 1)))
|
|
169
|
+
if (count > bestCount) {
|
|
170
|
+
best = value;
|
|
171
|
+
bestCount = count;
|
|
172
|
+
}
|
|
173
|
+
return best;
|
|
174
|
+
}
|
|
175
|
+
export function nameWorld(input) {
|
|
176
|
+
const { base, metrics, sites, states } = input;
|
|
177
|
+
const { geography, provinces } = base.input;
|
|
178
|
+
const kind = provinces.kind;
|
|
179
|
+
const features = base.features;
|
|
180
|
+
const rivers = riverOfProvince(base, geography);
|
|
181
|
+
const ranges = rangeOfProvince(base, metrics);
|
|
182
|
+
const places = placeNames(base);
|
|
183
|
+
const siteByProvince = new Map();
|
|
184
|
+
for (const located of sites) {
|
|
185
|
+
if (located.province === undefined || located.site.name === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
const list = siteByProvince.get(located.province) ?? [];
|
|
188
|
+
list.push(located);
|
|
189
|
+
siteByProvince.set(located.province, list);
|
|
190
|
+
}
|
|
191
|
+
const countryName = (tag) => tag === undefined ? 'Free Land' : (input.countryNames.get(tag) ?? tag);
|
|
192
|
+
const notes = [];
|
|
193
|
+
// Lake provinces: the lake's own name.
|
|
194
|
+
const lakes = new Map();
|
|
195
|
+
for (let id = 0; id < base.view.count; id += 1)
|
|
196
|
+
if (kind[id] === 2) {
|
|
197
|
+
const lake = features.lake[id];
|
|
198
|
+
const name = lake >= 0 ? geography.lakes[lake].name : undefined;
|
|
199
|
+
if (name !== undefined)
|
|
200
|
+
lakes.set(id, name);
|
|
201
|
+
}
|
|
202
|
+
// States.
|
|
203
|
+
const stateCandidates = new Map();
|
|
204
|
+
const stateAt = new Map();
|
|
205
|
+
for (const state of states) {
|
|
206
|
+
const candidates = [];
|
|
207
|
+
let sx = 0;
|
|
208
|
+
let sy = 0;
|
|
209
|
+
let weight = 0;
|
|
210
|
+
const lakeVotes = [];
|
|
211
|
+
const riverVotes = [];
|
|
212
|
+
const rangeVotes = [];
|
|
213
|
+
const placeVotes = [];
|
|
214
|
+
const regionVotes = [];
|
|
215
|
+
const landVotes = [];
|
|
216
|
+
let rangeProvinces = 0;
|
|
217
|
+
for (const id of state.provinces) {
|
|
218
|
+
const centre = centerOf(provinces, id);
|
|
219
|
+
sx += centre.x * provinces.size[id];
|
|
220
|
+
sy += centre.y * provinces.size[id];
|
|
221
|
+
weight += provinces.size[id];
|
|
222
|
+
for (const other of provinces.neighbors[id])
|
|
223
|
+
if (kind[other] === 2 || (kind[other] === SEA && features.lake[other] >= 0))
|
|
224
|
+
lakeVotes.push(geography.lakes[features.lake[other]]?.name);
|
|
225
|
+
const river = rivers[id];
|
|
226
|
+
if (river >= 0)
|
|
227
|
+
riverVotes.push(geography.rivers[river].name);
|
|
228
|
+
const range = ranges[id];
|
|
229
|
+
if (range >= 0) {
|
|
230
|
+
rangeVotes.push(geography.ranges[range].name);
|
|
231
|
+
rangeProvinces += 1;
|
|
232
|
+
}
|
|
233
|
+
for (const place of places.get(id) ?? [])
|
|
234
|
+
placeVotes.push(place);
|
|
235
|
+
const regionIndex = features.region[id];
|
|
236
|
+
regionVotes.push(regionIndex >= 0 ? geography.regions[regionIndex].name : undefined);
|
|
237
|
+
const landIndex = features.land[id];
|
|
238
|
+
landVotes.push(landIndex >= 0 ? geography.lands[landIndex].name : undefined);
|
|
239
|
+
for (const located of siteByProvince.get(id) ?? []) {
|
|
240
|
+
const strength = SITE_WEIGHT[located.site.siteKind] * (1 + located.site.importance);
|
|
241
|
+
const settlement = SETTLEMENT_KINDS.has(located.site.siteKind);
|
|
242
|
+
candidates.push({
|
|
243
|
+
name: located.site.name,
|
|
244
|
+
rank: settlement ? 20 - strength : 45 + (6 - strength) / 10,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
stateAt.set(state.id, { x: sx / weight, y: sy / weight });
|
|
249
|
+
const lake = mostCommon(lakeVotes);
|
|
250
|
+
if (lake !== undefined)
|
|
251
|
+
candidates.push({ name: `${bare(lake)} Shore`, rank: 30 });
|
|
252
|
+
const river = mostCommon(riverVotes);
|
|
253
|
+
if (river !== undefined && riverVotes.length >= Math.max(2, state.provinces.length * 0.4))
|
|
254
|
+
candidates.push({ name: `${bare(river)} Valley`, rank: 40 });
|
|
255
|
+
const range = mostCommon(rangeVotes);
|
|
256
|
+
if (range !== undefined && rangeProvinces * 2 >= state.provinces.length)
|
|
257
|
+
candidates.push({ name: `${bare(range)} Foothills`, rank: 50 });
|
|
258
|
+
const place = mostCommon(placeVotes);
|
|
259
|
+
if (place !== undefined)
|
|
260
|
+
candidates.push({ name: `${bare(place)} Coast`, rank: 60 });
|
|
261
|
+
const region = mostCommon(regionVotes);
|
|
262
|
+
if (region !== undefined)
|
|
263
|
+
candidates.push({ name: region, rank: 70 });
|
|
264
|
+
const land = mostCommon(landVotes);
|
|
265
|
+
if (land !== undefined)
|
|
266
|
+
candidates.push({ name: land, rank: 80 });
|
|
267
|
+
candidates.sort((a, b) => a.rank - b.rank || (a.name < b.name ? -1 : 1));
|
|
268
|
+
stateCandidates.set(state.id, candidates);
|
|
269
|
+
}
|
|
270
|
+
const stateBases = [];
|
|
271
|
+
const stateNames = new Map();
|
|
272
|
+
let statePlaceholders = 0;
|
|
273
|
+
for (const state of states) {
|
|
274
|
+
const fixed = input.fixedStates?.get(state.id);
|
|
275
|
+
if (fixed !== undefined) {
|
|
276
|
+
stateNames.set(state.id, fixed);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const best = stateCandidates.get(state.id)[0];
|
|
280
|
+
if (best === undefined) {
|
|
281
|
+
stateNames.set(state.id, `${countryName(state.owner)} District ${state.id}`);
|
|
282
|
+
statePlaceholders += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
stateBases.push({ key: state.id, name: best.name, at: stateAt.get(state.id) });
|
|
286
|
+
}
|
|
287
|
+
// A site name is itself unique by construction when no other state shares it.
|
|
288
|
+
for (const [key, name] of uniqueNames(stateBases))
|
|
289
|
+
stateNames.set(key, name);
|
|
290
|
+
// Strategic regions.
|
|
291
|
+
const regionMembers = new Map();
|
|
292
|
+
for (const [id, region] of input.regionOf) {
|
|
293
|
+
const list = regionMembers.get(region) ?? [];
|
|
294
|
+
list.push(id);
|
|
295
|
+
regionMembers.set(region, list);
|
|
296
|
+
}
|
|
297
|
+
const regionNames = new Map();
|
|
298
|
+
const regionBases = [];
|
|
299
|
+
let regionPlaceholders = 0;
|
|
300
|
+
for (const [region, members] of [...regionMembers].sort((a, b) => a[0] - b[0])) {
|
|
301
|
+
const fixed = input.fixedRegions?.get(region);
|
|
302
|
+
if (fixed !== undefined) {
|
|
303
|
+
regionNames.set(region, fixed);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
let sx = 0;
|
|
307
|
+
let sy = 0;
|
|
308
|
+
let weight = 0;
|
|
309
|
+
const weights = new Map();
|
|
310
|
+
const vote = (name, size) => {
|
|
311
|
+
if (name !== undefined)
|
|
312
|
+
weights.set(name, (weights.get(name) ?? 0) + size);
|
|
313
|
+
};
|
|
314
|
+
for (const id of members) {
|
|
315
|
+
const centre = centerOf(provinces, id);
|
|
316
|
+
sx += centre.x * provinces.size[id];
|
|
317
|
+
sy += centre.y * provinces.size[id];
|
|
318
|
+
weight += provinces.size[id];
|
|
319
|
+
const size = provinces.size[id];
|
|
320
|
+
if (kind[id] === SEA) {
|
|
321
|
+
const sea = features.sea[id];
|
|
322
|
+
const lake = features.lake[id];
|
|
323
|
+
vote(sea >= 0 ? geography.seas[sea].name : undefined, size);
|
|
324
|
+
if (sea < 0 && lake >= 0)
|
|
325
|
+
vote(geography.lakes[lake].name, size);
|
|
326
|
+
}
|
|
327
|
+
else if (kind[id] === LAND) {
|
|
328
|
+
const regionIndex = features.region[id];
|
|
329
|
+
vote(regionIndex >= 0 ? geography.regions[regionIndex].name : undefined, size);
|
|
330
|
+
const landIndex = features.land[id];
|
|
331
|
+
if (regionIndex < 0)
|
|
332
|
+
vote(landIndex >= 0 ? geography.lands[landIndex].name : undefined, size);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
let name;
|
|
336
|
+
let best = 0;
|
|
337
|
+
for (const [candidate, size] of [...weights].sort((a, b) => (a[0] < b[0] ? -1 : 1)))
|
|
338
|
+
if (size > best) {
|
|
339
|
+
name = candidate;
|
|
340
|
+
best = size;
|
|
341
|
+
}
|
|
342
|
+
if (name === undefined) {
|
|
343
|
+
regionNames.set(region, `Region ${region}`);
|
|
344
|
+
regionPlaceholders += 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
regionBases.push({ key: region, name, at: { x: sx / weight, y: sy / weight } });
|
|
348
|
+
}
|
|
349
|
+
for (const [key, name] of uniqueNames(regionBases))
|
|
350
|
+
regionNames.set(key, name);
|
|
351
|
+
// Victory points: sites by their own name, the rest by what stands around them.
|
|
352
|
+
const usedVictory = new Set();
|
|
353
|
+
const victory = new Map();
|
|
354
|
+
let victoryPlaceholders = 0;
|
|
355
|
+
let victoryTotal = 0;
|
|
356
|
+
const pending = [];
|
|
357
|
+
for (const state of states) {
|
|
358
|
+
const points = input.victoryPoints.get(state.id);
|
|
359
|
+
if (points === undefined)
|
|
360
|
+
continue;
|
|
361
|
+
for (const id of [...points.keys()].sort((a, b) => a - b)) {
|
|
362
|
+
victoryTotal += 1;
|
|
363
|
+
const own = siteByProvince.get(id);
|
|
364
|
+
const named = own?.sort((a, b) => SITE_WEIGHT[b.site.siteKind] * b.site.importance -
|
|
365
|
+
SITE_WEIGHT[a.site.siteKind] * a.site.importance || (a.site.id < b.site.id ? -1 : 1))[0];
|
|
366
|
+
const centre = centerOf(provinces, id);
|
|
367
|
+
if (named !== undefined) {
|
|
368
|
+
pending.push({ key: id, name: named.site.name, at: centre });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const riverIndex = rivers[id];
|
|
372
|
+
const lakeShore = [...provinces.neighbors[id]]
|
|
373
|
+
.map((other) => features.lake[other] >= 0 && kind[other] !== LAND
|
|
374
|
+
? geography.lakes[features.lake[other]].name
|
|
375
|
+
: undefined)
|
|
376
|
+
.find((name) => name !== undefined);
|
|
377
|
+
const stateName = stateNames.get(state.id);
|
|
378
|
+
const placeholderState = stateName.includes(' District ');
|
|
379
|
+
const derived = riverIndex >= 0
|
|
380
|
+
? `${bare(geography.rivers[riverIndex].name)} Ford`
|
|
381
|
+
: lakeShore !== undefined
|
|
382
|
+
? `${bare(lakeShore)} Landing`
|
|
383
|
+
: (places.get(id)?.[0] ?? (placeholderState ? undefined : `${stateName} Town`));
|
|
384
|
+
if (derived === undefined) {
|
|
385
|
+
victory.set(id, `${countryName(state.owner)} Settlement ${id + 1}`);
|
|
386
|
+
victoryPlaceholders += 1;
|
|
387
|
+
}
|
|
388
|
+
else
|
|
389
|
+
pending.push({ key: id, name: derived, at: centre });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const [key, name] of uniqueNames(pending)) {
|
|
393
|
+
let text = name;
|
|
394
|
+
for (let n = 2; usedVictory.has(text); n += 1)
|
|
395
|
+
text = `${name} ${roman(n - 1)}`;
|
|
396
|
+
usedVictory.add(text);
|
|
397
|
+
victory.set(key, text);
|
|
398
|
+
}
|
|
399
|
+
const counts = {
|
|
400
|
+
states: { total: states.length, placeholders: statePlaceholders },
|
|
401
|
+
regions: { total: regionMembers.size, placeholders: regionPlaceholders },
|
|
402
|
+
victoryPoints: { total: victoryTotal, placeholders: victoryPlaceholders },
|
|
403
|
+
};
|
|
404
|
+
const total = counts.states.total + counts.regions.total + counts.victoryPoints.total;
|
|
405
|
+
const placeholders = counts.states.placeholders + counts.regions.placeholders + counts.victoryPoints.placeholders;
|
|
406
|
+
if (placeholders > 0)
|
|
407
|
+
notes.push({
|
|
408
|
+
code: 'MAP_NAME_PLACEHOLDER',
|
|
409
|
+
severity: 'warning',
|
|
410
|
+
message: `${placeholders} of ${total} states, strategic regions and victory points (${((placeholders / Math.max(1, total)) * 100).toFixed(1)}%) have no name in the geography model and keep a placeholder ("District N", "Region N", "Settlement N"): ${counts.states.placeholders} of ${counts.states.total} states, ${counts.regions.placeholders} of ${counts.regions.total} strategic regions, ${counts.victoryPoints.placeholders} of ${counts.victoryPoints.total} victory points. Name more regions, lands, seas, rivers, lakes or sites in the model`,
|
|
411
|
+
details: {
|
|
412
|
+
placeholders,
|
|
413
|
+
total,
|
|
414
|
+
share: Math.round((placeholders / Math.max(1, total)) * 1000) / 1000,
|
|
415
|
+
...counts,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
return { states: stateNames, regions: regionNames, victory, lakes, counts, notes };
|
|
419
|
+
}
|