hoi4-agent-tools 4.5.0 → 4.6.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 (74) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  3. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  4. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  5. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  6. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  7. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  8. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  9. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  10. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  16. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  17. package/dist/hoi4_agent_tools/map/operations.js +109 -32
  18. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  19. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
  20. package/dist/hoi4_agent_tools/map/service.js +78 -16
  21. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  22. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  23. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  24. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  25. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  26. package/dist/hoi4_agent_tools/map/tool-operations.js +78 -0
  27. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  28. package/dist/hoi4_agent_tools/map/world.js +301 -107
  29. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  30. package/dist/hoi4_agent_tools/schemas/map.js +206 -21
  31. package/dist/hoi4_agent_tools/version.js +1 -1
  32. package/docs/map.md +98 -4
  33. package/npm-shrinkwrap.json +2 -2
  34. package/package.json +1 -1
  35. package/schemas/configuration.schema.json +1 -1
  36. package/schemas/continuous-focus-palette.schema.json +1 -1
  37. package/schemas/custom-weighted-pool.schema.json +1 -1
  38. package/schemas/decision-inspect-input.schema.json +1 -1
  39. package/schemas/error-log-input.schema.json +1 -1
  40. package/schemas/event-feature-manifest.schema.json +1 -1
  41. package/schemas/focus-plan.schema.json +1 -1
  42. package/schemas/focus-planning-sidecar.schema.json +1 -1
  43. package/schemas/geography-model.schema.json +13 -1
  44. package/schemas/gui-animation-source.schema.json +1 -1
  45. package/schemas/gui-helper.schema.json +1 -1
  46. package/schemas/gui-scenario.schema.json +1 -1
  47. package/schemas/helper-expansion-request.schema.json +1 -1
  48. package/schemas/helper-expansion-summary.schema.json +1 -1
  49. package/schemas/impact-inspect-input.schema.json +1 -1
  50. package/schemas/map-operation.schema.json +1355 -736
  51. package/schemas/mechanic-test-input.schema.json +1 -1
  52. package/schemas/mod-index-input.schema.json +1 -1
  53. package/schemas/operation-result.schema.json +1 -1
  54. package/schemas/package-check-input.schema.json +1 -1
  55. package/schemas/package-manifest.schema.json +1 -1
  56. package/schemas/probability-analysis-result.schema.json +1 -1
  57. package/schemas/probability-compare-input.schema.json +1 -1
  58. package/schemas/probability-evaluate-input.schema.json +1 -1
  59. package/schemas/probability-inspect-input.schema.json +1 -1
  60. package/schemas/probability-render-input.schema.json +1 -1
  61. package/schemas/probability-scenario-set.schema.json +1 -1
  62. package/schemas/probability-sequence-input.schema.json +1 -1
  63. package/schemas/probability-simulate-input.schema.json +1 -1
  64. package/schemas/probability-sweep-input.schema.json +1 -1
  65. package/schemas/reference-context-input.schema.json +1 -1
  66. package/schemas/reference-read-input.schema.json +1 -1
  67. package/schemas/reference-search-input.schema.json +1 -1
  68. package/schemas/reference-sheets.schema.json +1 -1
  69. package/schemas/scenario-suite.schema.json +1 -1
  70. package/schemas/scenario-test-input.schema.json +1 -1
  71. package/schemas/script-validate-input.schema.json +1 -1
  72. package/schemas/shared-scenario.schema.json +1 -1
  73. package/schemas/source-lookup-input.schema.json +1 -1
  74. package/server.json +2 -2
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The development model of a geography world (ADR 0063): population, state category,
3
+ * infrastructure, factories, resources and victory points follow from the fertility the model
4
+ * states, river access, the coast, passes and the model's sites, not from random rolls. Every
5
+ * number is a fixed function of those inputs, so two runs agree and a change in the model moves
6
+ * the economy in the direction the model says.
7
+ */
8
+ import { nearestOnPolyline } from '../geography/index.js';
9
+ import { LAND, centerOf } from './facts.js';
10
+ import { fertilityOf } from './metrics.js';
11
+ import { SITE_VICTORY_POINTS, SITE_WEIGHT, VICTORY_SITE_KINDS } from './sites.js';
12
+ function clamp01(value) {
13
+ return value < 0 ? 0 : value > 1 ? 1 : value;
14
+ }
15
+ /** The ore a range gives, by its profile. */
16
+ const RANGE_RESOURCE = {
17
+ ridge: 'steel',
18
+ massif: 'tungsten',
19
+ escarpment: 'chromium',
20
+ wall: 'steel',
21
+ };
22
+ export function developStates(base, metrics, sites, terrain, states, ports, options) {
23
+ const { geography, provinces, width, height } = base.input;
24
+ const kind = provinces.kind;
25
+ const count = base.view.count;
26
+ const fertility = new Float32Array(count);
27
+ const province = new Float32Array(count);
28
+ // Sites by province.
29
+ const siteStrength = new Float64Array(count);
30
+ const victorySites = new Map();
31
+ for (const { site, province: id } of sites) {
32
+ if (id === undefined)
33
+ continue;
34
+ siteStrength[id] = Math.max(siteStrength[id], SITE_WEIGHT[site.siteKind] * (0.4 + (site.importance * 0.6) / 5));
35
+ if (VICTORY_SITE_KINDS.has(site.siteKind))
36
+ victorySites.set(id, Math.max(victorySites.get(id) ?? 0, SITE_VICTORY_POINTS[site.importance - 1] ?? 3));
37
+ }
38
+ const gateProvince = new Set();
39
+ for (const [a, b] of base.crossings.gates) {
40
+ gateProvince.add(a);
41
+ gateProvince.add(b);
42
+ }
43
+ const hasSea = (id) => [...provinces.neighbors[id]].some((other) => kind[other] === 0);
44
+ const hasLake = (id) => [...provinces.neighbors[id]].some((other) => kind[other] === 2);
45
+ for (let id = 0; id < count; id += 1) {
46
+ if (kind[id] !== LAND)
47
+ continue;
48
+ const f = fertilityOf(metrics, id, terrain.get(id) ?? 'plains');
49
+ fertility[id] = f;
50
+ const river = clamp01(metrics.riverPixels[id] / Math.max(8, 2 * Math.sqrt(provinces.size[id])));
51
+ province[id] = clamp01(0.55 * f +
52
+ 0.12 * river +
53
+ 0.1 * (hasSea(id) ? 1 : 0) +
54
+ 0.06 * (hasLake(id) ? 1 : 0) +
55
+ 0.08 * (gateProvince.has(id) ? 1 : 0) +
56
+ 0.3 * siteStrength[id] -
57
+ 0.15 * metrics.mountain[id]);
58
+ }
59
+ // Ore from the ranges: provinces of mountain terrain near a range crest, richest first.
60
+ const resourcesOf = new Map();
61
+ const addResource = (id, name, amount) => {
62
+ const own = resourcesOf.get(id) ?? new Map();
63
+ own.set(name, (own.get(name) ?? 0) + amount);
64
+ resourcesOf.set(id, own);
65
+ };
66
+ for (const range of geography.ranges) {
67
+ const members = [];
68
+ for (let id = 0; id < count; id += 1) {
69
+ if (kind[id] !== LAND || metrics.mountain[id] < 0.3)
70
+ continue;
71
+ const centre = centerOf(provinces, id);
72
+ const near = nearestOnPolyline(range.path, geography.frame, centre.x / width, centre.y / height);
73
+ if (near.distance <= Math.max(near.halfWidth * 1.5, 0.004))
74
+ members.push(id);
75
+ }
76
+ if (members.length === 0)
77
+ continue;
78
+ members.sort((a, b) => metrics.mountain[b] - metrics.mountain[a] || a - b);
79
+ const deposits = Math.max(1, Math.ceil(members.length / 3));
80
+ let peak = 0;
81
+ for (const value of range.path.peak)
82
+ peak += value;
83
+ peak /= Math.max(1, range.path.peak.length);
84
+ const amount = 2 + Math.round(6 * peak);
85
+ const resource = RANGE_RESOURCE[range.profile] ?? 'steel';
86
+ for (const id of members.slice(0, deposits))
87
+ addResource(id, resource, amount);
88
+ }
89
+ // Rubber in the richest jungle.
90
+ const jungle = [...terrain]
91
+ .filter(([id, name]) => name === 'jungle' && kind[id] === LAND)
92
+ .map(([id]) => id)
93
+ .sort((a, b) => fertility[b] - fertility[a] || a - b);
94
+ for (const id of jungle.slice(0, Math.ceil(jungle.length * 0.4)))
95
+ addResource(id, 'rubber', 1 + Math.round(4 * fertility[id]));
96
+ // Mine sites name their resource (steel when they do not).
97
+ for (const { site, province: id } of sites)
98
+ if (site.siteKind === 'mine' && id !== undefined)
99
+ addResource(id, site.resource ?? 'steel', 2 + site.importance * 3);
100
+ const result = new Map();
101
+ const urban = new Set();
102
+ const categories = options.categories;
103
+ for (const state of states) {
104
+ let pixels = 0;
105
+ let fertilitySum = 0;
106
+ let developmentSum = 0;
107
+ let bestSite = 0;
108
+ let bestProvince = state.provinces[0];
109
+ for (const id of state.provinces) {
110
+ const size = provinces.size[id];
111
+ pixels += size;
112
+ fertilitySum += fertility[id] * size;
113
+ developmentSum += province[id] * size;
114
+ bestSite = Math.max(bestSite, siteStrength[id]);
115
+ if (province[id] > province[bestProvince] ||
116
+ (province[id] === province[bestProvince] &&
117
+ provinces.size[id] > provinces.size[bestProvince]))
118
+ bestProvince = id;
119
+ }
120
+ const meanFertility = fertilitySum / pixels;
121
+ const development = clamp01(developmentSum / pixels + 0.25 * bestSite + (state.isCapital ? 0.25 : 0));
122
+ const barren = meanFertility < 0.1 && bestSite === 0 && !state.isCapital;
123
+ let index = Math.round(development * 5.2 + (state.isCapital ? 1 : 0) + bestSite * 1.5);
124
+ index = Math.max(barren ? 0 : 1, Math.min(categories.length - 1, index));
125
+ let category = categories[index];
126
+ if (options.allowedCategories !== undefined && !options.allowedCategories.has(category))
127
+ category = options.allowedCategories.has('rural')
128
+ ? 'rural'
129
+ : ([...options.allowedCategories][0] ?? 'rural');
130
+ const slots = options.slotsFor[category] ?? 2;
131
+ const civilian = Math.round(slots * (0.25 + 0.3 * development));
132
+ const military = Math.min(slots - civilian, Math.round(slots * 0.3 * development));
133
+ const stateResources = new Map();
134
+ for (const id of state.provinces)
135
+ for (const [name, amount] of resourcesOf.get(id) ?? [])
136
+ stateResources.set(name, (stateResources.get(name) ?? 0) + amount);
137
+ const victoryPoints = new Map();
138
+ for (const id of state.provinces) {
139
+ const value = victorySites.get(id);
140
+ if (value !== undefined)
141
+ victoryPoints.set(id, value);
142
+ }
143
+ for (const id of state.provinces)
144
+ if (options.capitalProvinces.has(id))
145
+ victoryPoints.set(id, Math.max(25, victoryPoints.get(id) ?? 0));
146
+ if (victoryPoints.size === 0)
147
+ victoryPoints.set(bestProvince, Math.max(1, Math.round(province[bestProvince] * 8)));
148
+ for (const [id, value] of victoryPoints)
149
+ if (value >= 8)
150
+ urban.add(id);
151
+ const airBase = state.isCapital ? 2 : 0;
152
+ result.set(state.id, {
153
+ id: state.id,
154
+ manpower: Math.max(1_000, Math.round((pixels * 180 * Math.max(0.08, development)) / 1_000) * 1_000),
155
+ category,
156
+ infrastructure: Math.max(1, Math.min(5, Math.round(1 + development * 3 + (state.isCapital ? 1 : 0)))),
157
+ civilian,
158
+ military,
159
+ dockyard: ports.dockyards.has(state.id) && slots - civilian - military > 0 ? 1 : 0,
160
+ airBase,
161
+ resources: stateResources,
162
+ victoryPoints,
163
+ development,
164
+ fertility: meanFertility,
165
+ });
166
+ }
167
+ return { states: result, urban, province, fertility };
168
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The second stage of the geography-driven gameplay layer (ADR 0063): once states, strategic
3
+ * regions and owners exist, plan the ports, the development of every state, the names and the
4
+ * railway cost model from the model, and gather the notes they raise.
5
+ */
6
+ import { computeMetrics } from './metrics.js';
7
+ import { developStates } from './development.js';
8
+ import { nameWorld } from './names.js';
9
+ import { harbourScores, planPorts } from './ports.js';
10
+ import { buildRailCost } from './rail-cost.js';
11
+ import { locateSites } from './sites.js';
12
+ export function planEconomy(base, input) {
13
+ const metrics = computeMetrics(base.input, base.view);
14
+ const sites = locateSites(base);
15
+ const harbours = harbourScores(base, metrics, sites);
16
+ const ports = planPorts(base, harbours, sites, input.states.map((state) => ({
17
+ id: state.id,
18
+ provinces: state.provinces,
19
+ owner: state.owner,
20
+ capital: state.isCapital,
21
+ })));
22
+ const development = developStates(base, metrics, sites, input.terrain, input.states, ports, input.options);
23
+ const victoryPoints = new Map();
24
+ for (const [id, state] of development.states)
25
+ victoryPoints.set(id, state.victoryPoints);
26
+ const names = nameWorld({
27
+ base,
28
+ metrics,
29
+ sites,
30
+ states: input.states,
31
+ regionOf: input.regionOf,
32
+ fixedStates: input.fixedStateNames,
33
+ fixedRegions: input.fixedRegionNames,
34
+ victoryPoints,
35
+ countryNames: input.countryNames,
36
+ });
37
+ const terrain = new Map(input.terrain);
38
+ for (const id of development.urban)
39
+ terrain.set(id, 'urban');
40
+ const trunk = new Set();
41
+ for (const state of development.states.values())
42
+ for (const [province, value] of state.victoryPoints)
43
+ if (value >= 8)
44
+ trunk.add(province + 1);
45
+ const railCost = buildRailCost(base, metrics, terrain, trunk);
46
+ const forts = new Map();
47
+ for (const { province, level } of base.crossings.forts)
48
+ forts.set(province, Math.max(forts.get(province) ?? 0, level));
49
+ const notes = [...ports.notes, ...names.notes];
50
+ let waterSites = 0;
51
+ for (const located of sites) {
52
+ if (located.province === undefined && !located.site.onWater)
53
+ waterSites += 1;
54
+ }
55
+ if (waterSites > 0)
56
+ notes.push({
57
+ code: 'MAP_SITE_UNPLACED',
58
+ severity: 'warning',
59
+ message: `${waterSites} site(s) of the model lie farther than a few pixels from any land province, so they name nothing and give no victory point`,
60
+ details: { sites: waterSites },
61
+ });
62
+ notes.push({
63
+ code: 'MAP_DEVELOPMENT_PLANNED',
64
+ severity: 'info',
65
+ message: `Planned ${ports.ports.size} port(s), ${development.states.size} state economies, ${names.counts.states.total - names.counts.states.placeholders} of ${names.counts.states.total} state names and ${base.crossings.summary.straits + base.crossings.summary.canals} crossing(s) from the geography model`,
66
+ details: {
67
+ ports: ports.ports.size,
68
+ states: development.states.size,
69
+ namedStates: names.counts.states.total - names.counts.states.placeholders,
70
+ namedRegions: names.counts.regions.total - names.counts.regions.placeholders,
71
+ sites: sites.length,
72
+ },
73
+ });
74
+ return { metrics, sites, harbours, ports, development, names, railCost, forts, notes };
75
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Facts about the provinces of a generated world that the geography-driven stages share
3
+ * (ADR 0062, 0063): which model land, region, sea or lake each province belongs to, how the
4
+ * provinces border each other, and where they lie. Everything is computed from the province
5
+ * raster in single passes with typed arrays and no randomness, so two runs give the same facts.
6
+ */
7
+ import { scanAreaStrip, } from '../geography/index.js';
8
+ export const SEA = 0;
9
+ export const LAND = 1;
10
+ export const LAKE = 2;
11
+ /** The coarse grid the model's areas are painted on for containment votes. */
12
+ export const FACT_GRID_WIDTH = 1_024;
13
+ export function factGrid(width, height) {
14
+ const scale = Math.min(1, FACT_GRID_WIDTH / width);
15
+ return {
16
+ width: Math.max(1, Math.round(width * scale)),
17
+ height: Math.max(1, Math.round(height * scale)),
18
+ };
19
+ }
20
+ /**
21
+ * Paint areas on a grid: each cell holds the 1-based position of the last area that covers it
22
+ * (0 for none), so later entries win where areas overlap.
23
+ */
24
+ export function paintAreas(areas, grid) {
25
+ const data = new Uint16Array(grid.width * grid.height);
26
+ const strip = { y0: 0, y1: grid.height };
27
+ for (const [position, area] of areas.entries()) {
28
+ const value = position + 1;
29
+ scanAreaStrip(area, grid, strip, (y, x0, x1) => {
30
+ data.fill(value, y * grid.width + x0, y * grid.width + x1);
31
+ });
32
+ }
33
+ return data;
34
+ }
35
+ /**
36
+ * For every province the value its sampled pixels most often take in a painted raster; 0 when no
37
+ * sample falls on a painted cell. Ties go to the smaller value. `stride` samples every n-th pixel
38
+ * on both axes.
39
+ */
40
+ export function provinceModes(view, raster, grid, stride = 3, only, minShare = 0) {
41
+ const votes = new Map();
42
+ const samples = new Int32Array(view.count);
43
+ const { width, height, pixel } = view;
44
+ for (let y = 0; y < height; y += stride) {
45
+ const gy = Math.min(grid.height - 1, Math.floor(((y + 0.5) * grid.height) / height));
46
+ for (let x = 0; x < width; x += stride) {
47
+ const gx = Math.min(grid.width - 1, Math.floor(((x + 0.5) * grid.width) / width));
48
+ const value = raster[gy * grid.width + gx];
49
+ const province = pixel[y * width + x];
50
+ if (only !== undefined && !only(province))
51
+ continue;
52
+ samples[province] += 1;
53
+ if (value === 0)
54
+ continue;
55
+ const key = province * 65_536 + value;
56
+ votes.set(key, (votes.get(key) ?? 0) + 1);
57
+ }
58
+ }
59
+ const best = new Int32Array(view.count);
60
+ const bestVotes = new Int32Array(view.count);
61
+ for (const [key, count] of [...votes].sort((left, right) => left[0] - right[0])) {
62
+ const province = Math.floor(key / 65_536);
63
+ if (count > bestVotes[province] && count >= minShare * samples[province]) {
64
+ bestVotes[province] = count;
65
+ best[province] = key - province * 65_536;
66
+ }
67
+ }
68
+ return best;
69
+ }
70
+ export function pairKey(a, b, count) {
71
+ return a < b ? a * count + b : b * count + a;
72
+ }
73
+ export function pairOf(key, count) {
74
+ const a = Math.floor(key / count);
75
+ return [a, key - a * count];
76
+ }
77
+ /**
78
+ * Every border between two provinces, wrapping across the east-west seam. Pixel pairs are the
79
+ * right-hand and lower neighbours of each pixel.
80
+ */
81
+ export function buildBorderTable(input) {
82
+ const { width, height, pixel, count } = input.view;
83
+ const { heights, rivers } = input;
84
+ const table = new Map();
85
+ const visit = (a, b, p, q) => {
86
+ const key = pairKey(a, b, count);
87
+ let stat = table.get(key);
88
+ if (stat === undefined) {
89
+ stat = { count: 0, rivers: 0, step: 0, top: 0, sumX: 0, sumY: 0 };
90
+ table.set(key, stat);
91
+ }
92
+ stat.count += 1;
93
+ if (rivers[p] <= 11 || rivers[q] <= 11)
94
+ stat.rivers += 1;
95
+ const hp = heights[p];
96
+ const hq = heights[q];
97
+ stat.step += hp > hq ? hp - hq : hq - hp;
98
+ stat.top += hp > hq ? hp : hq;
99
+ stat.sumX += p % width;
100
+ stat.sumY += Math.floor(p / width);
101
+ };
102
+ for (let y = 0; y < height; y += 1) {
103
+ const row = y * width;
104
+ for (let x = 0; x < width; x += 1) {
105
+ const p = row + x;
106
+ const a = pixel[p];
107
+ const right = row + (x + 1 === width ? 0 : x + 1);
108
+ const b = pixel[right];
109
+ if (a !== b)
110
+ visit(a, b, p, right);
111
+ if (y + 1 < height) {
112
+ const c = pixel[p + width];
113
+ if (a !== c)
114
+ visit(a, c, p, p + width);
115
+ }
116
+ }
117
+ }
118
+ return table;
119
+ }
120
+ export function centerOf(source, id) {
121
+ const size = source.size[id];
122
+ return { x: source.sumX[id] / size, y: source.sumY[id] / size };
123
+ }
124
+ /** Horizontal difference on a wrapping map. */
125
+ export function wrapDelta(a, b, width) {
126
+ const delta = a - b;
127
+ const half = width / 2;
128
+ return delta > half ? delta - width : delta < -half ? delta + width : delta;
129
+ }
130
+ export function distanceOnMap(ax, ay, bx, by, width) {
131
+ const dx = wrapDelta(ax, bx, width);
132
+ const dy = ay - by;
133
+ return Math.sqrt(dx * dx + dy * dy);
134
+ }
135
+ export function provinceFeatures(input) {
136
+ const { geography, view, kind } = input;
137
+ const grid = factGrid(view.width, view.height);
138
+ const landRaster = paintAreas(geography.lands.map((land) => land.area), grid);
139
+ const regionIndex = [];
140
+ const regionAreas = [];
141
+ for (const [position, region] of geography.regions.entries())
142
+ if (region.area !== undefined) {
143
+ regionIndex.push(position);
144
+ regionAreas.push(region.area);
145
+ }
146
+ const regionRaster = paintAreas(regionAreas, grid);
147
+ const seaRaster = paintAreas(geography.seas.map((sea) => sea.area), grid);
148
+ const lakeRaster = paintAreas(geography.lakes.map((lake) => lake.area), grid);
149
+ const isLand = (province) => kind[province] === LAND;
150
+ const isSea = (province) => kind[province] === SEA;
151
+ const lands = provinceModes(view, landRaster, grid, 3, isLand);
152
+ const regions = provinceModes(view, regionRaster, grid, 3, isLand);
153
+ const seas = provinceModes(view, seaRaster, grid, 3, isSea);
154
+ const lakes = provinceModes(view, lakeRaster, grid, 3, (province) => !isLand(province), 0.5);
155
+ const out = {
156
+ land: new Int32Array(view.count).fill(-1),
157
+ region: new Int32Array(view.count).fill(-1),
158
+ sea: new Int32Array(view.count).fill(-1),
159
+ lake: new Int32Array(view.count).fill(-1),
160
+ };
161
+ for (let province = 0; province < view.count; province += 1) {
162
+ if (lands[province] > 0)
163
+ out.land[province] = lands[province] - 1;
164
+ if (regions[province] > 0)
165
+ out.region[province] = regionIndex[regions[province] - 1];
166
+ // Water inside a lake polygon belongs to the lake (an inland sea), not to the sea drawn under it.
167
+ if (seas[province] > 0 && lakes[province] === 0)
168
+ out.sea[province] = seas[province] - 1;
169
+ if (lakes[province] > 0)
170
+ out.lake[province] = lakes[province] - 1;
171
+ }
172
+ return out;
173
+ }
174
+ /** Nearest province to a pixel position among `candidates`, by centre distance. */
175
+ export function nearestProvince(source, width, candidates, x, y) {
176
+ let best;
177
+ let bestDistance = Number.POSITIVE_INFINITY;
178
+ for (const id of candidates) {
179
+ const centre = centerOf(source, id);
180
+ const d = distanceOnMap(centre.x, centre.y, x, y, width);
181
+ if (d < bestDistance) {
182
+ bestDistance = d;
183
+ best = id;
184
+ }
185
+ }
186
+ return best;
187
+ }
188
+ export function provinceViewOf(provinces, width, height) {
189
+ return { width, height, pixel: provinces.pixel, count: provinces.kind.length };
190
+ }
191
+ /**
192
+ * Pixels of province `a` that touch province `b` (4-connected), for each requested unordered pair
193
+ * `(a, b)` given as `a * count + b` in both orders as needed. One pass over the raster.
194
+ * Returns, for every requested key `a * count + b` (ordered), the pixel indices of `a` next to `b`.
195
+ */
196
+ export function collectContacts(view, wanted) {
197
+ const { width, height, pixel, count } = view;
198
+ const out = new Map();
199
+ if (wanted.size === 0)
200
+ return out;
201
+ const touch = (own, other, at) => {
202
+ const key = own * count + other;
203
+ if (!wanted.has(key))
204
+ return;
205
+ let list = out.get(key);
206
+ if (list === undefined) {
207
+ list = [];
208
+ out.set(key, list);
209
+ }
210
+ if (list[list.length - 1] !== at)
211
+ list.push(at);
212
+ };
213
+ for (let y = 0; y < height; y += 1) {
214
+ const row = y * width;
215
+ for (let x = 0; x < width; x += 1) {
216
+ const p = row + x;
217
+ const a = pixel[p];
218
+ const right = row + (x + 1 === width ? 0 : x + 1);
219
+ const b = pixel[right];
220
+ if (a !== b) {
221
+ touch(a, b, p);
222
+ touch(b, a, right);
223
+ }
224
+ if (y + 1 < height) {
225
+ const q = p + width;
226
+ const c = pixel[q];
227
+ if (a !== c) {
228
+ touch(a, c, p);
229
+ touch(c, a, q);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ return out;
235
+ }
236
+ /** The nearest pair of pixels between two contact lists, subsampled to at most `limit` each. */
237
+ export function closestApproach(left, right, width, limit = 300) {
238
+ if (left.length === 0 || right.length === 0)
239
+ return undefined;
240
+ const stepL = Math.max(1, Math.ceil(left.length / limit));
241
+ const stepR = Math.max(1, Math.ceil(right.length / limit));
242
+ let best = Number.POSITIVE_INFINITY;
243
+ let bestL = -1;
244
+ let bestR = -1;
245
+ for (let i = 0; i < left.length; i += stepL) {
246
+ const lx = left[i] % width;
247
+ const ly = Math.floor(left[i] / width);
248
+ for (let j = 0; j < right.length; j += stepR) {
249
+ const dx = wrapDelta(lx, right[j] % width, width);
250
+ const dy = ly - Math.floor(right[j] / width);
251
+ const d = dx * dx + dy * dy;
252
+ if (d < best) {
253
+ best = d;
254
+ bestL = left[i];
255
+ bestR = right[j];
256
+ }
257
+ }
258
+ }
259
+ return {
260
+ gap: Math.sqrt(best),
261
+ from: { x: bestL % width, y: Math.floor(bestL / width) },
262
+ to: { x: bestR % width, y: Math.floor(bestR / width) },
263
+ };
264
+ }
@@ -0,0 +1,66 @@
1
+ import { LAKE, SEA, labelComponents } from '../relief/grid.js';
2
+ import { factGrid, paintAreas } from './facts.js';
3
+ /**
4
+ * Turn the lake bodies that belong to inland-sea lakes into sea. `kind` is modified in place (and
5
+ * `elevation`, to the sea value the province generator expects).
6
+ */
7
+ export function convertInlandSeas(kind, elevation, width, height, geography) {
8
+ const wanted = geography.lakes
9
+ .map((lake, position) => ({ lake, position }))
10
+ .filter(({ lake }) => lake.gameplay === 'inland-sea');
11
+ if (wanted.length === 0)
12
+ return { converted: [], missing: [] };
13
+ const grid = factGrid(width, height);
14
+ const painted = paintAreas(wanted.map(({ lake }) => lake.area), grid);
15
+ const components = labelComponents(width, height, (index) => kind[index] === LAKE);
16
+ const votes = new Map();
17
+ for (let y = 0; y < height; y += 2) {
18
+ const gy = Math.min(grid.height - 1, Math.floor(((y + 0.5) * grid.height) / height));
19
+ for (let x = 0; x < width; x += 2) {
20
+ const label = components.label[y * width + x];
21
+ if (label < 0)
22
+ continue;
23
+ const value = painted[gy * grid.width + Math.min(grid.width - 1, Math.floor(((x + 0.5) * grid.width) / width))];
24
+ if (value === 0)
25
+ continue;
26
+ const key = label * 4_096 + (value - 1);
27
+ votes.set(key, (votes.get(key) ?? 0) + 1);
28
+ }
29
+ }
30
+ // A body belongs to the inland-sea lake with most of its samples; it must be mostly inside it.
31
+ const total = new Map();
32
+ for (let y = 0; y < height; y += 2)
33
+ for (let x = 0; x < width; x += 2) {
34
+ const label = components.label[y * width + x];
35
+ if (label >= 0)
36
+ total.set(label, (total.get(label) ?? 0) + 1);
37
+ }
38
+ const owner = new Map();
39
+ for (const [key, count] of [...votes].sort((a, b) => a[0] - b[0])) {
40
+ const label = Math.floor(key / 4_096);
41
+ const lake = key - label * 4_096;
42
+ if (count * 2 < (total.get(label) ?? 0))
43
+ continue;
44
+ const best = owner.get(label);
45
+ const bestCount = best === undefined ? 0 : (votes.get(label * 4_096 + best) ?? 0);
46
+ if (count > bestCount)
47
+ owner.set(label, lake);
48
+ }
49
+ const pixelsOf = new Map();
50
+ for (let index = 0; index < kind.length; index += 1) {
51
+ const label = components.label[index];
52
+ if (label < 0 || !owner.has(label))
53
+ continue;
54
+ kind[index] = SEA;
55
+ elevation[index] = -0.15;
56
+ const lake = owner.get(label);
57
+ pixelsOf.set(lake, (pixelsOf.get(lake) ?? 0) + 1);
58
+ }
59
+ const converted = [...pixelsOf]
60
+ .sort((a, b) => a[0] - b[0])
61
+ .map(([lake, pixels]) => ({ lake: wanted[lake].position, pixels }));
62
+ const missing = wanted
63
+ .filter((_, position) => !pixelsOf.has(position))
64
+ .map(({ position }) => position);
65
+ return { converted, missing };
66
+ }