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,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Straits, canals, passes and impassable ranges of a world built from a geography model
|
|
3
|
+
* (ADR 0062). A model strait becomes a named `sea` crossing in `adjacencies.csv` with its rule in
|
|
4
|
+
* `adjacency_rules.txt`; a range declared `barrier: 'impassable'` becomes `impassable` rows for
|
|
5
|
+
* the land provinces on its two sides, except at the declared passes, which stay open; passes
|
|
6
|
+
* with a fort level place land forts on the provinces that hold the gate.
|
|
7
|
+
*
|
|
8
|
+
* The planner reads only the province raster, its graph and the resolved model. It never writes
|
|
9
|
+
* files; `world.ts` writes the rows and the rules text it returns.
|
|
10
|
+
*/
|
|
11
|
+
import { ServiceError } from '../../core/result.js';
|
|
12
|
+
import { clearBandStrip, createBandStrip, rasterizeBandStrip, rowStrips, sampleAlong, } from '../geography/index.js';
|
|
13
|
+
import { LAKE, LAND, SEA, centerOf, closestApproach, collectContacts, distanceOnMap, pairKey, pairOf, } from './facts.js';
|
|
14
|
+
/** Hard cap on `impassable` rows (the base game has 93). */
|
|
15
|
+
export const IMPASSABLE_ROW_LIMIT = 2_000;
|
|
16
|
+
/** Largest gap of an automatically detected strait, in pixels (the base game's largest is 30). */
|
|
17
|
+
export const DEFAULT_AUTO_STRAIT_GAP_PX = 24;
|
|
18
|
+
/** A model strait whose shores are farther apart than this (or 1.5 times its width) is reported. */
|
|
19
|
+
export const STRAIT_NARROW_LIMIT_PX = 48;
|
|
20
|
+
/** Most automatically detected straits one world may add. */
|
|
21
|
+
export const AUTO_STRAIT_LIMIT = 400;
|
|
22
|
+
function ruleNameFor(id, used) {
|
|
23
|
+
const base = id
|
|
24
|
+
.toUpperCase()
|
|
25
|
+
.replace(/[^A-Z0-9]+/gu, '_')
|
|
26
|
+
.replace(/^_+|_+$/gu, '') || 'CROSSING';
|
|
27
|
+
let name = base;
|
|
28
|
+
for (let suffix = 2; used.has(name); suffix += 1)
|
|
29
|
+
name = `${base}_${suffix}`;
|
|
30
|
+
used.add(name);
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
function commentOf(name, id) {
|
|
34
|
+
return (name ?? id).replace(/[;\r\n]+/gu, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------------------------
|
|
37
|
+
// Geometry helpers
|
|
38
|
+
/** Pixels (as indices) along a polyline of frame points, in order, without immediate repeats. */
|
|
39
|
+
function pixelsAlong(points, width, height) {
|
|
40
|
+
const out = [];
|
|
41
|
+
const px = (value, size) => Math.max(0, Math.min(size - 1, Math.floor(value * size)));
|
|
42
|
+
for (let index = 0; index + 1 < points.length; index += 1) {
|
|
43
|
+
const ax = px(points[index].x, width);
|
|
44
|
+
const ay = px(points[index].y, height);
|
|
45
|
+
const bx = px(points[index + 1].x, width);
|
|
46
|
+
const by = px(points[index + 1].y, height);
|
|
47
|
+
const steps = Math.max(Math.abs(bx - ax), Math.abs(by - ay), 1);
|
|
48
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
49
|
+
const x = Math.floor(ax + ((bx - ax) * step) / steps + 0.5);
|
|
50
|
+
const y = Math.floor(ay + ((by - ay) * step) / steps + 0.5);
|
|
51
|
+
const pixel = y * width + x;
|
|
52
|
+
if (out[out.length - 1] !== pixel)
|
|
53
|
+
out.push(pixel);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (points.length === 1)
|
|
57
|
+
out.push(px(points[0].y, height) * width + px(points[0].x, width));
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** The nearest pair of ring vertices between two lands, as frame points. */
|
|
61
|
+
function nearestVertices(geography, first, second) {
|
|
62
|
+
const a = geography.get('land', first);
|
|
63
|
+
const b = geography.get('land', second);
|
|
64
|
+
if (a === undefined || b === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const aspect = geography.frame.height / geography.frame.width;
|
|
67
|
+
let best = Number.POSITIVE_INFINITY;
|
|
68
|
+
let pair;
|
|
69
|
+
for (const ringA of a.area.outers)
|
|
70
|
+
for (let i = 0; i < ringA.xs.length; i += 1)
|
|
71
|
+
for (const ringB of b.area.outers)
|
|
72
|
+
for (let j = 0; j < ringB.xs.length; j += 1) {
|
|
73
|
+
const dx = ringA.xs[i] - ringB.xs[j];
|
|
74
|
+
const dy = (ringA.ys[i] - ringB.ys[j]) * aspect;
|
|
75
|
+
const d = dx * dx + dy * dy;
|
|
76
|
+
if (d < best) {
|
|
77
|
+
best = d;
|
|
78
|
+
pair = [
|
|
79
|
+
{ x: ringA.xs[i], y: ringA.ys[i] },
|
|
80
|
+
{ x: ringB.xs[j], y: ringB.ys[j] },
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return pair;
|
|
85
|
+
}
|
|
86
|
+
function nearestVertexTo(geography, landId, point) {
|
|
87
|
+
const land = geography.get('land', landId);
|
|
88
|
+
if (land === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
const aspect = geography.frame.height / geography.frame.width;
|
|
91
|
+
let best = Number.POSITIVE_INFINITY;
|
|
92
|
+
let found;
|
|
93
|
+
for (const ring of land.area.outers)
|
|
94
|
+
for (let i = 0; i < ring.xs.length; i += 1) {
|
|
95
|
+
const dx = ring.xs[i] - point.x;
|
|
96
|
+
const dy = (ring.ys[i] - point.y) * aspect;
|
|
97
|
+
const d = dx * dx + dy * dy;
|
|
98
|
+
if (d < best) {
|
|
99
|
+
best = d;
|
|
100
|
+
found = { x: ring.xs[i], y: ring.ys[i] };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
/** The probe line a strait is resolved along, in frame coordinates. */
|
|
106
|
+
function straitProbe(geography, strait, width, height) {
|
|
107
|
+
if (strait.path !== undefined && strait.path.length >= 2)
|
|
108
|
+
return [...strait.path];
|
|
109
|
+
const [first, second] = strait.between;
|
|
110
|
+
let ends;
|
|
111
|
+
if (first.type === 'land' && second.type === 'land')
|
|
112
|
+
ends = nearestVertices(geography, first.id, second.id);
|
|
113
|
+
else {
|
|
114
|
+
const pointOf = (end, other) => {
|
|
115
|
+
if (end.type === 'point')
|
|
116
|
+
return { x: end.x, y: end.y };
|
|
117
|
+
if (other.type === 'point')
|
|
118
|
+
return nearestVertexTo(geography, end.id, other);
|
|
119
|
+
return undefined;
|
|
120
|
+
};
|
|
121
|
+
const a = pointOf(first, second);
|
|
122
|
+
const b = pointOf(second, first);
|
|
123
|
+
if (a !== undefined && b !== undefined)
|
|
124
|
+
ends = [a, b];
|
|
125
|
+
}
|
|
126
|
+
if (ends === undefined)
|
|
127
|
+
return [];
|
|
128
|
+
// The ends lie on coasts; run the line a little way inland so it starts and ends on land.
|
|
129
|
+
const dx = (ends[1].x - ends[0].x) * width;
|
|
130
|
+
const dy = (ends[1].y - ends[0].y) * height;
|
|
131
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
132
|
+
if (length < 1)
|
|
133
|
+
return ends;
|
|
134
|
+
const reach = 14;
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
x: ends[0].x - ((dx / length) * reach) / width,
|
|
138
|
+
y: ends[0].y - ((dy / length) * reach) / height,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
x: ends[1].x + ((dx / length) * reach) / width,
|
|
142
|
+
y: ends[1].y + ((dy / length) * reach) / height,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Find (from, through, to) along a sequence of provinces: the two ends are of one kind (land or
|
|
148
|
+
* sea), the middle one of the other kind, and the middle one touches both ends. Land to land is
|
|
149
|
+
* preferred; `expected` names the model lands the ends should belong to.
|
|
150
|
+
*/
|
|
151
|
+
function tripleAlong(sequence, pixelsIn, input, expected) {
|
|
152
|
+
// Maximal runs of provinces of one kind along the line; a lake ends a run.
|
|
153
|
+
const runs = [];
|
|
154
|
+
for (const province of sequence) {
|
|
155
|
+
const kind = input.kind[province];
|
|
156
|
+
const last = runs[runs.length - 1];
|
|
157
|
+
if (last?.kind === kind)
|
|
158
|
+
last.members.push(province);
|
|
159
|
+
else
|
|
160
|
+
runs.push({ kind, members: [province] });
|
|
161
|
+
}
|
|
162
|
+
let best;
|
|
163
|
+
for (let r = 1; r + 1 < runs.length; r += 1) {
|
|
164
|
+
const middle = runs[r];
|
|
165
|
+
const before = runs[r - 1];
|
|
166
|
+
const after = runs[r + 1];
|
|
167
|
+
if (middle.kind === LAKE || before.kind !== after.kind || before.kind === middle.kind)
|
|
168
|
+
continue;
|
|
169
|
+
if (before.kind === LAKE)
|
|
170
|
+
continue;
|
|
171
|
+
const from = before.members[before.members.length - 1];
|
|
172
|
+
const to = after.members[0];
|
|
173
|
+
if (from === to || input.neighbors[from].has(to))
|
|
174
|
+
continue;
|
|
175
|
+
// The through province touches both shores when it can; narrow channels cut in two reach
|
|
176
|
+
// only one, as six of the base game's 151 crossings do.
|
|
177
|
+
let through = middle.members[0];
|
|
178
|
+
let throughScore = Number.NEGATIVE_INFINITY;
|
|
179
|
+
for (const member of middle.members) {
|
|
180
|
+
const touching = (input.neighbors[member].has(from) ? 1 : 0) + (input.neighbors[member].has(to) ? 1 : 0);
|
|
181
|
+
const score = touching * 1_000_000 + (pixelsIn.get(member) ?? 0);
|
|
182
|
+
if (score > throughScore) {
|
|
183
|
+
throughScore = score;
|
|
184
|
+
through = member;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!input.neighbors[through].has(from) && !input.neighbors[through].has(to))
|
|
188
|
+
continue;
|
|
189
|
+
let score = throughScore / 1_000_000;
|
|
190
|
+
if (before.kind === LAND)
|
|
191
|
+
score += 100;
|
|
192
|
+
if (expected[0] !== undefined && input.features.land[from] === expected[0])
|
|
193
|
+
score += 10;
|
|
194
|
+
if (expected[1] !== undefined && input.features.land[to] === expected[1])
|
|
195
|
+
score += 10;
|
|
196
|
+
if (best === undefined || score > best.score)
|
|
197
|
+
best = {
|
|
198
|
+
triple: { from, through, to, via: middle.members },
|
|
199
|
+
crossing: before.kind === LAND ? 'land-to-land' : 'sea-to-sea',
|
|
200
|
+
score,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return best === undefined ? undefined : { triple: best.triple, crossing: best.crossing };
|
|
204
|
+
}
|
|
205
|
+
function landIndexOf(geography, end) {
|
|
206
|
+
const id = end.type === 'land' ? end.id : end.landId;
|
|
207
|
+
if (id === undefined)
|
|
208
|
+
return undefined;
|
|
209
|
+
const index = geography.lands.findIndex((land) => land.id === id);
|
|
210
|
+
return index < 0 ? undefined : index;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* When the line crosses no clean land-sea-land triple, look for a sea province that touches land
|
|
214
|
+
* provinces of both declared lands near the strait's middle.
|
|
215
|
+
*/
|
|
216
|
+
function tripleBySearch(strait, probe, input, expected) {
|
|
217
|
+
if (expected[0] === undefined || expected[1] === undefined)
|
|
218
|
+
return undefined;
|
|
219
|
+
const { width, height } = input;
|
|
220
|
+
const midX = ((probe[0].x + probe[probe.length - 1].x) / 2) * width;
|
|
221
|
+
const midY = ((probe[0].y + probe[probe.length - 1].y) / 2) * height;
|
|
222
|
+
if (expected[0] === expected[1])
|
|
223
|
+
return sameLandTriple(probe, input, expected[0], midX, midY, strait.width * width);
|
|
224
|
+
const reach = Math.max(0.12 * width, strait.width * width * 4);
|
|
225
|
+
let best;
|
|
226
|
+
for (let through = 0; through < input.kind.length; through += 1) {
|
|
227
|
+
if (input.kind[through] !== SEA)
|
|
228
|
+
continue;
|
|
229
|
+
const centre = centerOf(input.center, through);
|
|
230
|
+
const d = distanceOnMap(centre.x, centre.y, midX, midY, width);
|
|
231
|
+
if (d > reach)
|
|
232
|
+
continue;
|
|
233
|
+
let from;
|
|
234
|
+
let to;
|
|
235
|
+
for (const other of [...input.neighbors[through]].sort((a, b) => a - b)) {
|
|
236
|
+
if (input.kind[other] !== LAND)
|
|
237
|
+
continue;
|
|
238
|
+
if (input.features.land[other] === expected[0] && from === undefined)
|
|
239
|
+
from = other;
|
|
240
|
+
else if (input.features.land[other] === expected[1] && to === undefined)
|
|
241
|
+
to = other;
|
|
242
|
+
}
|
|
243
|
+
if (from === undefined || to === undefined || input.neighbors[from].has(to))
|
|
244
|
+
continue;
|
|
245
|
+
if (best === undefined || d < best.score)
|
|
246
|
+
best = { from, through, to, via: [through], score: d };
|
|
247
|
+
}
|
|
248
|
+
return best === undefined
|
|
249
|
+
? undefined
|
|
250
|
+
: { from: best.from, through: best.through, to: best.to, via: [best.through] };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* A crossing between two coasts of one land: the sea province nearest the strait's middle whose
|
|
254
|
+
* land neighbours on that land include one near each end that do not touch each other.
|
|
255
|
+
*/
|
|
256
|
+
function sameLandTriple(probe, input, land, midX, midY, straitWidth) {
|
|
257
|
+
const { width, height } = input;
|
|
258
|
+
const ends = [probe[0], probe[probe.length - 1]].map(({ x, y }) => ({
|
|
259
|
+
x: x * width,
|
|
260
|
+
y: y * height,
|
|
261
|
+
}));
|
|
262
|
+
const reach = Math.max(0.12 * width, straitWidth * 4);
|
|
263
|
+
let best;
|
|
264
|
+
for (let through = 0; through < input.kind.length; through += 1) {
|
|
265
|
+
if (input.kind[through] !== SEA)
|
|
266
|
+
continue;
|
|
267
|
+
const centre = centerOf(input.center, through);
|
|
268
|
+
const d = distanceOnMap(centre.x, centre.y, midX, midY, width);
|
|
269
|
+
if (d > reach)
|
|
270
|
+
continue;
|
|
271
|
+
const shores = [...input.neighbors[through]]
|
|
272
|
+
.filter((other) => input.kind[other] === LAND && input.features.land[other] === land)
|
|
273
|
+
.sort((a, b) => a - b);
|
|
274
|
+
const nearest = (end) => {
|
|
275
|
+
let chosen;
|
|
276
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
277
|
+
for (const shore of shores) {
|
|
278
|
+
const c = centerOf(input.center, shore);
|
|
279
|
+
const next = distanceOnMap(c.x, c.y, end.x, end.y, width);
|
|
280
|
+
if (next < distance) {
|
|
281
|
+
chosen = shore;
|
|
282
|
+
distance = next;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return chosen;
|
|
286
|
+
};
|
|
287
|
+
const from = nearest(ends[0]);
|
|
288
|
+
const to = nearest(ends[1]);
|
|
289
|
+
if (from === undefined || to === undefined || from === to || input.neighbors[from].has(to))
|
|
290
|
+
continue;
|
|
291
|
+
if (best === undefined || d < best.score)
|
|
292
|
+
best = { from, through, to, via: [through], score: d };
|
|
293
|
+
}
|
|
294
|
+
return best === undefined
|
|
295
|
+
? undefined
|
|
296
|
+
: { from: best.from, through: best.through, to: best.to, via: [best.through] };
|
|
297
|
+
}
|
|
298
|
+
function planModelStraits(input, notes) {
|
|
299
|
+
const out = [];
|
|
300
|
+
const { width, height } = input;
|
|
301
|
+
for (const strait of input.geography.straits) {
|
|
302
|
+
const probe = straitProbe(input.geography, strait, width, height);
|
|
303
|
+
if (probe.length < 2) {
|
|
304
|
+
notes.push({
|
|
305
|
+
code: 'MAP_STRAIT_UNRESOLVED',
|
|
306
|
+
severity: 'warning',
|
|
307
|
+
message: `Strait ${strait.id} has no usable line between its ends; no crossing was written`,
|
|
308
|
+
details: { straitId: strait.id },
|
|
309
|
+
});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const pixels = pixelsAlong(probe, width, height);
|
|
313
|
+
const sequence = [];
|
|
314
|
+
const pixelsIn = new Map();
|
|
315
|
+
for (const pixel of pixels) {
|
|
316
|
+
const province = input.view.pixel[pixel];
|
|
317
|
+
pixelsIn.set(province, (pixelsIn.get(province) ?? 0) + 1);
|
|
318
|
+
if (sequence[sequence.length - 1] !== province)
|
|
319
|
+
sequence.push(province);
|
|
320
|
+
}
|
|
321
|
+
const expected = [
|
|
322
|
+
landIndexOf(input.geography, strait.between[0]),
|
|
323
|
+
landIndexOf(input.geography, strait.between[1]),
|
|
324
|
+
];
|
|
325
|
+
let found = tripleAlong(sequence, pixelsIn, input, expected);
|
|
326
|
+
if (found === undefined) {
|
|
327
|
+
const searched = tripleBySearch(strait, probe, input, expected);
|
|
328
|
+
if (searched !== undefined)
|
|
329
|
+
found = { triple: searched, crossing: 'land-to-land' };
|
|
330
|
+
}
|
|
331
|
+
if (found === undefined) {
|
|
332
|
+
notes.push({
|
|
333
|
+
code: 'MAP_STRAIT_UNRESOLVED',
|
|
334
|
+
severity: 'warning',
|
|
335
|
+
message: `Strait ${strait.id} separates no two provinces through a single province between them; it needs a narrower channel or a path through one sea province`,
|
|
336
|
+
details: { straitId: strait.id, provincesAlongLine: sequence.length },
|
|
337
|
+
});
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
out.push({
|
|
341
|
+
id: strait.id,
|
|
342
|
+
name: strait.name,
|
|
343
|
+
rule: strait.rule,
|
|
344
|
+
declaredWidth: strait.width,
|
|
345
|
+
triple: found.triple,
|
|
346
|
+
crossing: found.crossing,
|
|
347
|
+
auto: false,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
/** Land provinces grouped by the landmass they belong to (connected over land borders). */
|
|
353
|
+
function landmasses(input) {
|
|
354
|
+
const label = new Int32Array(input.kind.length).fill(-1);
|
|
355
|
+
let next = 0;
|
|
356
|
+
for (let start = 0; start < input.kind.length; start += 1) {
|
|
357
|
+
if (input.kind[start] !== LAND || label[start] >= 0)
|
|
358
|
+
continue;
|
|
359
|
+
const stack = [start];
|
|
360
|
+
label[start] = next;
|
|
361
|
+
while (stack.length > 0) {
|
|
362
|
+
const current = stack.pop();
|
|
363
|
+
for (const other of input.neighbors[current])
|
|
364
|
+
if (input.kind[other] === LAND && label[other] < 0) {
|
|
365
|
+
label[other] = next;
|
|
366
|
+
stack.push(other);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
next += 1;
|
|
370
|
+
}
|
|
371
|
+
return label;
|
|
372
|
+
}
|
|
373
|
+
/** Narrow channels the model did not declare: two landmasses a single sea province separates. */
|
|
374
|
+
function planAutoStraits(input, taken, maxGapPx, notes) {
|
|
375
|
+
const mass = landmasses(input);
|
|
376
|
+
const pairs = [];
|
|
377
|
+
for (let through = 0; through < input.kind.length; through += 1) {
|
|
378
|
+
if (input.kind[through] !== SEA)
|
|
379
|
+
continue;
|
|
380
|
+
const byMass = new Map();
|
|
381
|
+
for (const other of [...input.neighbors[through]].sort((a, b) => a - b))
|
|
382
|
+
if (input.kind[other] === LAND) {
|
|
383
|
+
const list = byMass.get(mass[other]) ?? [];
|
|
384
|
+
list.push(other);
|
|
385
|
+
byMass.set(mass[other], list);
|
|
386
|
+
}
|
|
387
|
+
const masses = [...byMass.keys()].sort((a, b) => a - b);
|
|
388
|
+
for (let i = 0; i < masses.length; i += 1)
|
|
389
|
+
for (let j = i + 1; j < masses.length; j += 1)
|
|
390
|
+
for (const from of byMass.get(masses[i]))
|
|
391
|
+
for (const to of byMass.get(masses[j]))
|
|
392
|
+
if (!taken.has(`${Math.min(from, to)}:${Math.max(from, to)}`))
|
|
393
|
+
pairs.push({ from, through, to, via: [through] });
|
|
394
|
+
}
|
|
395
|
+
const wanted = new Set();
|
|
396
|
+
for (const pair of pairs) {
|
|
397
|
+
wanted.add(pair.from * input.view.count + pair.through);
|
|
398
|
+
wanted.add(pair.to * input.view.count + pair.through);
|
|
399
|
+
}
|
|
400
|
+
const contacts = collectContacts(input.view, wanted);
|
|
401
|
+
const scored = [];
|
|
402
|
+
for (const pair of pairs) {
|
|
403
|
+
const left = contacts.get(pair.from * input.view.count + pair.through);
|
|
404
|
+
const right = contacts.get(pair.to * input.view.count + pair.through);
|
|
405
|
+
if (left === undefined || right === undefined)
|
|
406
|
+
continue;
|
|
407
|
+
const approach = closestApproach(left, right, input.width, 200);
|
|
408
|
+
if (approach !== undefined && approach.gap <= maxGapPx)
|
|
409
|
+
scored.push({ pair, gap: approach.gap });
|
|
410
|
+
}
|
|
411
|
+
scored.sort((a, b) => a.gap - b.gap ||
|
|
412
|
+
a.pair.from - b.pair.from ||
|
|
413
|
+
a.pair.to - b.pair.to ||
|
|
414
|
+
a.pair.through - b.pair.through);
|
|
415
|
+
const chosen = [];
|
|
416
|
+
const used = new Set();
|
|
417
|
+
for (const { pair } of scored) {
|
|
418
|
+
const key = `${Math.min(pair.from, pair.to)}:${Math.max(pair.from, pair.to)}`;
|
|
419
|
+
if (used.has(key))
|
|
420
|
+
continue;
|
|
421
|
+
if (chosen.length >= AUTO_STRAIT_LIMIT) {
|
|
422
|
+
notes.push({
|
|
423
|
+
code: 'MAP_STRAIT_AUTO_LIMIT',
|
|
424
|
+
severity: 'warning',
|
|
425
|
+
message: `More than ${AUTO_STRAIT_LIMIT} narrow channels were detected; only the ${AUTO_STRAIT_LIMIT} narrowest were written`,
|
|
426
|
+
details: { limit: AUTO_STRAIT_LIMIT, maxGapPx },
|
|
427
|
+
});
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
used.add(key);
|
|
431
|
+
chosen.push({
|
|
432
|
+
id: `auto_${pair.from + 1}_${pair.to + 1}`,
|
|
433
|
+
name: undefined,
|
|
434
|
+
rule: undefined,
|
|
435
|
+
declaredWidth: undefined,
|
|
436
|
+
triple: pair,
|
|
437
|
+
crossing: 'land-to-land',
|
|
438
|
+
auto: true,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return chosen;
|
|
442
|
+
}
|
|
443
|
+
/** A canal for each sealed inland sea: a land province between it and the open sea. */
|
|
444
|
+
function planInlandSeaCanals(input, connected, notes) {
|
|
445
|
+
const out = [];
|
|
446
|
+
if (input.inlandSeas.length === 0)
|
|
447
|
+
return out;
|
|
448
|
+
for (const lakeIndex of input.inlandSeas) {
|
|
449
|
+
const lake = input.geography.lakes[lakeIndex];
|
|
450
|
+
const members = [];
|
|
451
|
+
for (let id = 0; id < input.kind.length; id += 1)
|
|
452
|
+
if (input.kind[id] === SEA && input.features.lake[id] === lakeIndex)
|
|
453
|
+
members.push(id);
|
|
454
|
+
if (members.length === 0)
|
|
455
|
+
continue;
|
|
456
|
+
const inside = new Set(members);
|
|
457
|
+
const body = new Set(members);
|
|
458
|
+
// Extend over sea provinces joined to the members; a body that reaches outside is not sealed.
|
|
459
|
+
const stack = [...members];
|
|
460
|
+
let reachesOcean = false;
|
|
461
|
+
while (stack.length > 0) {
|
|
462
|
+
const current = stack.pop();
|
|
463
|
+
for (const other of input.neighbors[current])
|
|
464
|
+
if (input.kind[other] === SEA && !body.has(other)) {
|
|
465
|
+
if (input.features.lake[other] === lakeIndex) {
|
|
466
|
+
body.add(other);
|
|
467
|
+
inside.add(other);
|
|
468
|
+
stack.push(other);
|
|
469
|
+
}
|
|
470
|
+
else
|
|
471
|
+
reachesOcean = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (reachesOcean) {
|
|
475
|
+
notes.push({
|
|
476
|
+
code: 'MAP_INLAND_SEA_OPEN',
|
|
477
|
+
severity: 'info',
|
|
478
|
+
message: `Inland sea ${lake.id} touches the open sea; ships can sail between them and no canal was needed`,
|
|
479
|
+
details: { lakeId: lake.id, provinces: body.size },
|
|
480
|
+
});
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// A model strait that already joins the inland sea to another sea is the canal.
|
|
484
|
+
if ([...body].some((province) => connected.has(province))) {
|
|
485
|
+
notes.push({
|
|
486
|
+
code: 'MAP_INLAND_SEA_CONNECTED',
|
|
487
|
+
severity: 'info',
|
|
488
|
+
message: `Inland sea ${lake.id} is joined to the open sea by a declared strait`,
|
|
489
|
+
details: { lakeId: lake.id },
|
|
490
|
+
});
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
// The nearest sea beyond one land province, else beyond two (the canal's through province
|
|
494
|
+
// then touches the inland sea only, as the base game's narrow crossings sometimes do).
|
|
495
|
+
let best;
|
|
496
|
+
const consider = (candidate) => {
|
|
497
|
+
if (best === undefined || candidate.score < best.score)
|
|
498
|
+
best = candidate;
|
|
499
|
+
};
|
|
500
|
+
for (const sea of [...body].sort((a, b) => a - b)) {
|
|
501
|
+
const here = centerOf(input.center, sea);
|
|
502
|
+
for (const first of [...input.neighbors[sea]].sort((a, b) => a - b)) {
|
|
503
|
+
if (input.kind[first] !== LAND)
|
|
504
|
+
continue;
|
|
505
|
+
for (const far of [...input.neighbors[first]].sort((a, b) => a - b)) {
|
|
506
|
+
if (input.kind[far] === SEA && !body.has(far)) {
|
|
507
|
+
const there = centerOf(input.center, far);
|
|
508
|
+
consider({
|
|
509
|
+
from: sea,
|
|
510
|
+
through: first,
|
|
511
|
+
to: far,
|
|
512
|
+
via: [first],
|
|
513
|
+
score: distanceOnMap(here.x, here.y, there.x, there.y, input.width),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
else if (input.kind[far] === LAND && far !== first) {
|
|
517
|
+
for (const beyond of [...input.neighbors[far]].sort((a, b) => a - b)) {
|
|
518
|
+
if (input.kind[beyond] !== SEA || body.has(beyond))
|
|
519
|
+
continue;
|
|
520
|
+
const there = centerOf(input.center, beyond);
|
|
521
|
+
consider({
|
|
522
|
+
from: sea,
|
|
523
|
+
through: first,
|
|
524
|
+
to: beyond,
|
|
525
|
+
via: [first, far],
|
|
526
|
+
score: 10_000 + distanceOnMap(here.x, here.y, there.x, there.y, input.width),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (best === undefined) {
|
|
534
|
+
notes.push({
|
|
535
|
+
code: 'MAP_INLAND_SEA_SEALED',
|
|
536
|
+
severity: 'warning',
|
|
537
|
+
message: `Inland sea ${lake.id} lies more than two land provinces from the open sea, so ships cannot leave it; declare a strait with a path from the lake across the land, or move the lake nearer the coast`,
|
|
538
|
+
details: { lakeId: lake.id, provinces: body.size },
|
|
539
|
+
});
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const chosen = best;
|
|
543
|
+
out.push({
|
|
544
|
+
id: `${lake.id}_canal`,
|
|
545
|
+
name: lake.name === undefined ? undefined : `${lake.name} canal`,
|
|
546
|
+
rule: 'open',
|
|
547
|
+
declaredWidth: undefined,
|
|
548
|
+
triple: {
|
|
549
|
+
from: chosen.from,
|
|
550
|
+
through: chosen.through,
|
|
551
|
+
to: chosen.to,
|
|
552
|
+
via: chosen.via,
|
|
553
|
+
},
|
|
554
|
+
crossing: 'sea-to-sea',
|
|
555
|
+
auto: false,
|
|
556
|
+
lake: lakeIndex,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
function passHalfWindow(pass, width) {
|
|
562
|
+
return Math.max(6, pass.width * width * 0.75 + 4);
|
|
563
|
+
}
|
|
564
|
+
function scanRange(range, input) {
|
|
565
|
+
const { width, height, view } = input;
|
|
566
|
+
const grid = { width, height };
|
|
567
|
+
const passes = range.passIds
|
|
568
|
+
.map((id) => input.geography.get('pass', id))
|
|
569
|
+
.filter((pass) => pass !== undefined);
|
|
570
|
+
let halfWidthPx = 0;
|
|
571
|
+
for (const half of range.path.halfWidth)
|
|
572
|
+
halfWidthPx = Math.max(halfWidthPx, half * width);
|
|
573
|
+
// A wall is straight and the partition cuts along it; a ridge crest may wander off the path.
|
|
574
|
+
const band = range.profile === 'wall' ? 6 : Math.max(6, Math.min(40, 0.4 * halfWidthPx));
|
|
575
|
+
const hits = new Map();
|
|
576
|
+
const lengthPx = Math.max(1, range.path.length * width);
|
|
577
|
+
const windows = passes.map((pass) => passHalfWindow(pass, width) / lengthPx);
|
|
578
|
+
const inPass = (t) => {
|
|
579
|
+
for (const [position, pass] of passes.entries()) {
|
|
580
|
+
let delta = Math.abs(t - pass.at);
|
|
581
|
+
if (range.path.closed && delta > 0.5)
|
|
582
|
+
delta = 1 - delta;
|
|
583
|
+
if (delta <= windows[position])
|
|
584
|
+
return position;
|
|
585
|
+
}
|
|
586
|
+
return -1;
|
|
587
|
+
};
|
|
588
|
+
const x0 = Math.max(0, Math.floor(range.path.bounds.x0 * width - band - 2));
|
|
589
|
+
const x1 = Math.min(width - 2, Math.ceil(range.path.bounds.x1 * width + band + 2));
|
|
590
|
+
const y0 = Math.max(0, Math.floor(range.path.bounds.y0 * height - band - 2));
|
|
591
|
+
const y1 = Math.min(height - 1, Math.ceil(range.path.bounds.y1 * height + band + 2));
|
|
592
|
+
const rows = 64;
|
|
593
|
+
const shared = createBandStrip(grid, { y0: 0, y1: rows + 1 });
|
|
594
|
+
for (const strip of rowStrips({ width, height: y1 + 1 }, rows)) {
|
|
595
|
+
if (strip.y1 <= y0)
|
|
596
|
+
continue;
|
|
597
|
+
input.signal?.throwIfAborted();
|
|
598
|
+
const extended = { y0: strip.y0, y1: Math.min(height, strip.y1 + 1) };
|
|
599
|
+
const active = { ...shared, strip: extended };
|
|
600
|
+
clearBandStrip(active);
|
|
601
|
+
rasterizeBandStrip(range.path, grid, extended, band, active);
|
|
602
|
+
for (let y = Math.max(strip.y0, y0); y < strip.y1 && y <= y1; y += 1)
|
|
603
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
604
|
+
const cell = (y - extended.y0) * width + x;
|
|
605
|
+
const p = y * width + x;
|
|
606
|
+
if (active.distance[cell] === Infinity)
|
|
607
|
+
continue;
|
|
608
|
+
const a = view.pixel[p];
|
|
609
|
+
if (input.kind[a] !== LAND)
|
|
610
|
+
continue;
|
|
611
|
+
const probe = (q, qCell) => {
|
|
612
|
+
const b = view.pixel[q];
|
|
613
|
+
if (a === b || input.kind[b] !== LAND)
|
|
614
|
+
return;
|
|
615
|
+
const dp = active.distance[cell];
|
|
616
|
+
const dq = active.distance[qCell];
|
|
617
|
+
if (dq === Infinity)
|
|
618
|
+
return;
|
|
619
|
+
if (active.side[cell] * active.side[qCell] > 0)
|
|
620
|
+
return;
|
|
621
|
+
const t = dp <= dq ? active.t[cell] : active.t[qCell];
|
|
622
|
+
const key = pairKey(a, b, view.count);
|
|
623
|
+
let hit = hits.get(key);
|
|
624
|
+
if (hit === undefined) {
|
|
625
|
+
hit = { wall: 0, gate: new Map() };
|
|
626
|
+
hits.set(key, hit);
|
|
627
|
+
}
|
|
628
|
+
const pass = inPass(t);
|
|
629
|
+
if (pass >= 0)
|
|
630
|
+
hit.gate.set(pass, (hit.gate.get(pass) ?? 0) + 1);
|
|
631
|
+
else
|
|
632
|
+
hit.wall += 1;
|
|
633
|
+
};
|
|
634
|
+
if (x + 1 < width)
|
|
635
|
+
probe(p + 1, cell + 1);
|
|
636
|
+
if (y + 1 < extended.y1)
|
|
637
|
+
probe(p + width, cell + width);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return { range, hits, passes };
|
|
641
|
+
}
|
|
642
|
+
/** A pair is a wall crossing when enough of its border lies across the range's line. */
|
|
643
|
+
function isWallPair(hit, stat) {
|
|
644
|
+
if (hit.wall < 2)
|
|
645
|
+
return false;
|
|
646
|
+
const total = stat?.count ?? hit.wall;
|
|
647
|
+
return hit.wall >= 0.2 * total;
|
|
648
|
+
}
|
|
649
|
+
/** Border pixel pairs inside a pass window that free a pair: any border through the gate is the gate. */
|
|
650
|
+
const GATE_HITS = 1;
|
|
651
|
+
// ---------------------------------------------------------------------------------------------
|
|
652
|
+
// Access rules
|
|
653
|
+
function accessBlocks(access) {
|
|
654
|
+
const block = (name, values) => [
|
|
655
|
+
`\t${name} =`,
|
|
656
|
+
'\t{',
|
|
657
|
+
`\t\tarmy = ${values.army ? 'yes' : 'no'}`,
|
|
658
|
+
`\t\tnavy = ${values.navy ? 'yes' : 'no'}`,
|
|
659
|
+
`\t\tsubmarine = ${values.submarine ? 'yes' : 'no'}`,
|
|
660
|
+
`\t\ttrade = ${values.trade ? 'yes' : 'no'}`,
|
|
661
|
+
'\t}',
|
|
662
|
+
].join('\n');
|
|
663
|
+
const army = access !== 'army-closed';
|
|
664
|
+
const navy = access !== 'navy-closed';
|
|
665
|
+
const held = { army: false, navy: false, submarine: navy, trade: false };
|
|
666
|
+
const free = { army, navy, submarine: navy, trade: true };
|
|
667
|
+
return [
|
|
668
|
+
block('contested', held),
|
|
669
|
+
block('enemy', held),
|
|
670
|
+
block('friend', free),
|
|
671
|
+
block('neutral', free),
|
|
672
|
+
].join('\n');
|
|
673
|
+
}
|
|
674
|
+
/** `adjacency_rules.txt` for the rules of a plan; a header comment stays when there are none. */
|
|
675
|
+
export function adjacencyRulesText(rules) {
|
|
676
|
+
if (rules.length === 0)
|
|
677
|
+
return '# No straits or canals in this world yet.\n';
|
|
678
|
+
const blocks = rules.map((rule) => [
|
|
679
|
+
`# ${rule.comment}`,
|
|
680
|
+
'adjacency_rule =',
|
|
681
|
+
'{',
|
|
682
|
+
`\tname = "${rule.name}"`,
|
|
683
|
+
'',
|
|
684
|
+
accessBlocks(rule.access),
|
|
685
|
+
'',
|
|
686
|
+
`\trequired_provinces = { ${rule.required.join(' ')} }`,
|
|
687
|
+
'',
|
|
688
|
+
`\ticon = ${rule.icon}`,
|
|
689
|
+
'\toffset = { 0 0 0 }',
|
|
690
|
+
'}',
|
|
691
|
+
].join('\n'));
|
|
692
|
+
return `# Generated from the geography model: one rule for every named strait and canal.\n\n${blocks.join('\n\n')}\n`;
|
|
693
|
+
}
|
|
694
|
+
/** The rows of `adjacencies.csv` (header and terminator excluded) as text. */
|
|
695
|
+
export function adjacencyRowText(row) {
|
|
696
|
+
const line = row.line === undefined
|
|
697
|
+
? '-1;-1;-1;-1'
|
|
698
|
+
: `${row.line.startX};${row.line.startY};${row.line.stopX};${row.line.stopY}`;
|
|
699
|
+
return `${row.from};${row.to};${row.type};${row.type === 'impassable' ? -1 : row.through};${line};${row.rule};${row.comment}`;
|
|
700
|
+
}
|
|
701
|
+
// ---------------------------------------------------------------------------------------------
|
|
702
|
+
// The plan
|
|
703
|
+
export function planCrossings(input) {
|
|
704
|
+
const { geography, width, height } = input;
|
|
705
|
+
const view = input.view;
|
|
706
|
+
const notes = [];
|
|
707
|
+
const taken = new Set();
|
|
708
|
+
const seaCandidates = [];
|
|
709
|
+
const modelStraits = planModelStraits(input, notes);
|
|
710
|
+
for (const candidate of modelStraits) {
|
|
711
|
+
const key = `${Math.min(candidate.triple.from, candidate.triple.to)}:${Math.max(candidate.triple.from, candidate.triple.to)}`;
|
|
712
|
+
if (taken.has(key)) {
|
|
713
|
+
notes.push({
|
|
714
|
+
code: 'MAP_STRAIT_DUPLICATE',
|
|
715
|
+
severity: 'warning',
|
|
716
|
+
message: `Strait ${candidate.id} crosses the same two provinces as an earlier strait; its row was skipped`,
|
|
717
|
+
details: { straitId: candidate.id },
|
|
718
|
+
});
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
taken.add(key);
|
|
722
|
+
seaCandidates.push(candidate);
|
|
723
|
+
}
|
|
724
|
+
const joined = new Set();
|
|
725
|
+
for (const candidate of seaCandidates)
|
|
726
|
+
if (candidate.crossing === 'sea-to-sea') {
|
|
727
|
+
joined.add(candidate.triple.from);
|
|
728
|
+
joined.add(candidate.triple.to);
|
|
729
|
+
}
|
|
730
|
+
const canals = planInlandSeaCanals(input, joined, notes);
|
|
731
|
+
for (const canal of canals)
|
|
732
|
+
taken.add(`${Math.min(canal.triple.from, canal.triple.to)}:${Math.max(canal.triple.from, canal.triple.to)}`);
|
|
733
|
+
const autos = input.autoStraits === undefined
|
|
734
|
+
? []
|
|
735
|
+
: planAutoStraits(input, taken, input.autoStraits.maxGapPx, notes);
|
|
736
|
+
const everySea = [...seaCandidates, ...canals, ...autos];
|
|
737
|
+
// Closest approach of every crossing, from the shores that face the channel between them.
|
|
738
|
+
const wanted = new Set();
|
|
739
|
+
for (const { triple } of everySea)
|
|
740
|
+
for (const member of triple.via) {
|
|
741
|
+
wanted.add(triple.from * view.count + member);
|
|
742
|
+
wanted.add(triple.to * view.count + member);
|
|
743
|
+
}
|
|
744
|
+
const contacts = collectContacts(view, wanted);
|
|
745
|
+
const shore = (province, via) => via.flatMap((member) => contacts.get(province * view.count + member) ?? []);
|
|
746
|
+
const rows = [];
|
|
747
|
+
const rules = [];
|
|
748
|
+
const usedRules = new Set();
|
|
749
|
+
for (const candidate of everySea) {
|
|
750
|
+
const { from, through, to, via } = candidate.triple;
|
|
751
|
+
const approach = closestApproach(shore(from, via), shore(to, via), width);
|
|
752
|
+
const line = approach === undefined
|
|
753
|
+
? undefined
|
|
754
|
+
: {
|
|
755
|
+
startX: approach.from.x,
|
|
756
|
+
startY: height - 1 - approach.from.y,
|
|
757
|
+
stopX: approach.to.x,
|
|
758
|
+
stopY: height - 1 - approach.to.y,
|
|
759
|
+
};
|
|
760
|
+
const comment = candidate.auto
|
|
761
|
+
? `Auto strait ${from + 1}-${to + 1}`
|
|
762
|
+
: commentOf(candidate.name, candidate.id);
|
|
763
|
+
let rule = '';
|
|
764
|
+
if (!candidate.auto) {
|
|
765
|
+
rule = ruleNameFor(candidate.id, usedRules);
|
|
766
|
+
rules.push({
|
|
767
|
+
name: rule,
|
|
768
|
+
comment,
|
|
769
|
+
access: candidate.rule ?? 'open',
|
|
770
|
+
required: [...new Set([from + 1, to + 1, through + 1])].sort((a, b) => a - b),
|
|
771
|
+
icon: candidate.crossing === 'land-to-land' ? through + 1 : from + 1,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
rows.push({
|
|
775
|
+
from: from + 1,
|
|
776
|
+
to: to + 1,
|
|
777
|
+
type: 'sea',
|
|
778
|
+
through: through + 1,
|
|
779
|
+
...(line === undefined ? {} : { line }),
|
|
780
|
+
rule,
|
|
781
|
+
comment,
|
|
782
|
+
});
|
|
783
|
+
if (!candidate.auto && approach !== undefined) {
|
|
784
|
+
const limit = Math.max(STRAIT_NARROW_LIMIT_PX, candidate.declaredWidth === undefined ? 0 : 1.5 * candidate.declaredWidth * width);
|
|
785
|
+
if (candidate.crossing === 'land-to-land' && approach.gap > limit)
|
|
786
|
+
notes.push({
|
|
787
|
+
code: 'MAP_STRAIT_NOT_NARROW',
|
|
788
|
+
severity: 'warning',
|
|
789
|
+
message: `Strait ${candidate.id} is ${Math.round(approach.gap)} pixels wide where its shores are closest (about ${Math.round(approach.gap * 7.114)} km); the base game's widest crossing is 30 pixels. Narrow the channel in the model or accept the long hop`,
|
|
790
|
+
details: {
|
|
791
|
+
straitId: candidate.id,
|
|
792
|
+
gapPx: Math.round(approach.gap),
|
|
793
|
+
limitPx: Math.round(limit),
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// Ranges: impassable rows outside the passes, forts at the passes.
|
|
799
|
+
const blockedPairs = new Map();
|
|
800
|
+
const gatePairs = new Map();
|
|
801
|
+
const passReports = [];
|
|
802
|
+
const forts = [];
|
|
803
|
+
const scanned = [];
|
|
804
|
+
for (const range of geography.ranges) {
|
|
805
|
+
const hasFort = range.passIds.some((id) => (geography.get('pass', id)?.fort ?? 0) > 0);
|
|
806
|
+
if (range.barrier !== 'impassable' && !hasFort && range.barrier !== 'fort-line')
|
|
807
|
+
continue;
|
|
808
|
+
scanned.push(scanRange(range, input));
|
|
809
|
+
}
|
|
810
|
+
const rangeComment = (range) => commentOf(range.name, range.id);
|
|
811
|
+
for (const scan of scanned) {
|
|
812
|
+
const { range, hits, passes } = scan;
|
|
813
|
+
for (const [key, hit] of hits) {
|
|
814
|
+
const stat = input.borders.get(key);
|
|
815
|
+
let freed = false;
|
|
816
|
+
for (const count of hit.gate.values())
|
|
817
|
+
if (count >= GATE_HITS)
|
|
818
|
+
freed = true;
|
|
819
|
+
if (freed) {
|
|
820
|
+
gatePairs.set(key, hit.gate.size);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (range.barrier === 'impassable' && isWallPair(hit, stat) && !blockedPairs.has(key))
|
|
824
|
+
blockedPairs.set(key, rangeComment(range));
|
|
825
|
+
}
|
|
826
|
+
for (const [position, pass] of passes.entries()) {
|
|
827
|
+
const open = [];
|
|
828
|
+
for (const [key, hit] of hits) {
|
|
829
|
+
const count = hit.gate.get(position) ?? 0;
|
|
830
|
+
if (count >= GATE_HITS) {
|
|
831
|
+
const [a, b] = pairOf(key, view.count);
|
|
832
|
+
open.push([a, b, count]);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
open.sort((x, y) => y[2] - x[2] || x[0] - y[0] || x[1] - y[1]);
|
|
836
|
+
passReports.push({
|
|
837
|
+
id: pass.id,
|
|
838
|
+
range: range.id,
|
|
839
|
+
open: open.slice(0, 4).map(([a, b]) => [a, b]),
|
|
840
|
+
});
|
|
841
|
+
if (open.length === 0) {
|
|
842
|
+
notes.push({
|
|
843
|
+
code: 'MAP_PASS_BLOCKED',
|
|
844
|
+
severity: 'warning',
|
|
845
|
+
message: `Pass ${pass.id} of ${range.id} joins no two land provinces: its gap lies over water, over a single province or outside the land; units cannot use it as a gate`,
|
|
846
|
+
details: { passId: pass.id, rangeId: range.id },
|
|
847
|
+
});
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (pass.fort > 0) {
|
|
851
|
+
const best = open[0];
|
|
852
|
+
forts.push({ province: best[0], level: pass.fort, pass: pass.id });
|
|
853
|
+
forts.push({ province: best[1], level: pass.fort, pass: pass.id });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// A freed pair is never blocked, whichever range blocked it first.
|
|
858
|
+
for (const key of gatePairs.keys())
|
|
859
|
+
blockedPairs.delete(key);
|
|
860
|
+
const impassable = [...blockedPairs]
|
|
861
|
+
.map(([key, comment]) => {
|
|
862
|
+
const [a, b] = pairOf(key, view.count);
|
|
863
|
+
return { from: a + 1, to: b + 1, comment };
|
|
864
|
+
})
|
|
865
|
+
.sort((l, r) => l.from - r.from || l.to - r.to);
|
|
866
|
+
const rowLimit = input.impassableLimit ?? IMPASSABLE_ROW_LIMIT;
|
|
867
|
+
if (impassable.length > rowLimit)
|
|
868
|
+
throw new ServiceError('MAP_IMPASSABLE_ROW_LIMIT', `The impassable ranges need ${impassable.length} adjacency rows; the limit is ${rowLimit} (the base game has 93). Narrow the ranges, raise the province size or use barrier "fort-line"`, { rows: impassable.length, limit: rowLimit });
|
|
869
|
+
for (const { from, to, comment } of impassable)
|
|
870
|
+
rows.push({ from, to, type: 'impassable', through: -1, rule: '', comment });
|
|
871
|
+
// A wall that provinces straddle or that leaves a gap outside the declared passes.
|
|
872
|
+
const blockedSet = new Set(blockedPairs.keys());
|
|
873
|
+
for (const scan of scanned) {
|
|
874
|
+
if (scan.range.barrier !== 'impassable')
|
|
875
|
+
continue;
|
|
876
|
+
const { range } = scan;
|
|
877
|
+
const lengthPx = Math.max(1, range.path.length * width);
|
|
878
|
+
const stepT = Math.min(0.25, 4 / lengthPx);
|
|
879
|
+
let samples = 0;
|
|
880
|
+
let open = 0;
|
|
881
|
+
const gaps = [];
|
|
882
|
+
for (let t = 0; t <= 1; t += stepT) {
|
|
883
|
+
const sample = sampleAlong(range.path, geography.frame, t);
|
|
884
|
+
const insidePass = scan.passes.some((pass) => {
|
|
885
|
+
let delta = Math.abs(t - pass.at);
|
|
886
|
+
if (range.path.closed && delta > 0.5)
|
|
887
|
+
delta = 1 - delta;
|
|
888
|
+
return delta <= (passHalfWindow(pass, width) + 4) / lengthPx;
|
|
889
|
+
});
|
|
890
|
+
if (insidePass)
|
|
891
|
+
continue;
|
|
892
|
+
const cx = sample.x * width;
|
|
893
|
+
const cy = sample.y * height;
|
|
894
|
+
const ax = Math.round(cx - sample.tangentY * 3);
|
|
895
|
+
const ay = Math.round(cy + sample.tangentX * 3);
|
|
896
|
+
const bx = Math.round(cx + sample.tangentY * 3);
|
|
897
|
+
const by = Math.round(cy - sample.tangentX * 3);
|
|
898
|
+
if (ax < 0 ||
|
|
899
|
+
ay < 0 ||
|
|
900
|
+
bx < 0 ||
|
|
901
|
+
by < 0 ||
|
|
902
|
+
ax >= width ||
|
|
903
|
+
bx >= width ||
|
|
904
|
+
ay >= height ||
|
|
905
|
+
by >= height)
|
|
906
|
+
continue;
|
|
907
|
+
const a = view.pixel[ay * width + ax];
|
|
908
|
+
const b = view.pixel[by * width + bx];
|
|
909
|
+
if (input.kind[a] !== LAND || input.kind[b] !== LAND)
|
|
910
|
+
continue;
|
|
911
|
+
// A pair of provinces freed at a gate is a whole border; all of it counts as the gate.
|
|
912
|
+
if (a !== b && gatePairs.has(pairKey(a, b, view.count)))
|
|
913
|
+
continue;
|
|
914
|
+
samples += 1;
|
|
915
|
+
if (a === b || !blockedSet.has(pairKey(a, b, view.count))) {
|
|
916
|
+
open += 1;
|
|
917
|
+
gaps.push(Math.round(t * 1000) / 1000);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (open > 0)
|
|
921
|
+
notes.push({
|
|
922
|
+
code: 'MAP_RANGE_NOT_CLOSED',
|
|
923
|
+
severity: 'warning',
|
|
924
|
+
message: `Wall ${range.id} leaves ${open} of ${samples} sampled points open outside its declared passes (a province straddles the line, or the border does not follow it); those points can be crossed on foot`,
|
|
925
|
+
details: {
|
|
926
|
+
rangeId: range.id,
|
|
927
|
+
samples,
|
|
928
|
+
open,
|
|
929
|
+
at: gaps.slice(0, 8),
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
notes.push({
|
|
934
|
+
code: 'MAP_CROSSINGS_PLANNED',
|
|
935
|
+
severity: 'info',
|
|
936
|
+
message: `Wrote ${modelStraits.length > 0 ? seaCandidates.length : 0} model strait(s), ${canals.length} canal(s), ${autos.length} automatic strait(s) and ${impassable.length} impassable row(s) for ${scanned.filter((s) => s.range.barrier === 'impassable').length} wall range(s); ${passReports.length} pass(es) open, ${forts.length} fort site(s)`,
|
|
937
|
+
details: {
|
|
938
|
+
straits: seaCandidates.length,
|
|
939
|
+
canals: canals.length,
|
|
940
|
+
autoStraits: autos.length,
|
|
941
|
+
impassableRows: impassable.length,
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
return {
|
|
945
|
+
rows,
|
|
946
|
+
rules,
|
|
947
|
+
notes,
|
|
948
|
+
forts,
|
|
949
|
+
passes: passReports,
|
|
950
|
+
blocked: [...blockedPairs.keys()].map((key) => pairOf(key, view.count)),
|
|
951
|
+
gates: [...gatePairs.keys()].map((key) => pairOf(key, view.count)),
|
|
952
|
+
summary: {
|
|
953
|
+
straits: seaCandidates.length,
|
|
954
|
+
autoStraits: autos.length,
|
|
955
|
+
canals: canals.length,
|
|
956
|
+
impassableRows: impassable.length,
|
|
957
|
+
ranges: scanned.length,
|
|
958
|
+
passes: passReports.length,
|
|
959
|
+
},
|
|
960
|
+
};
|
|
961
|
+
}
|