hoi4-agent-tools 3.9.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +115 -147
- package/dist/bin/http.js +12 -0
- package/dist/bin/setup.js +3 -2
- package/dist/bin/stdio.js +12 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
- package/dist/hoi4_agent_tools/core/canonical.js +18 -4
- package/dist/hoi4_agent_tools/core/configuration.js +7 -0
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
- package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
- package/dist/hoi4_agent_tools/core/engine.js +56 -10
- package/dist/hoi4_agent_tools/core/index.js +45 -20
- package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
- package/dist/hoi4_agent_tools/core/job-store.js +78 -5
- package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
- package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
- package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
- package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
- package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
- package/dist/hoi4_agent_tools/core/server-registry.js +155 -0
- package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
- package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
- package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
- package/dist/hoi4_agent_tools/core/transactions.js +114 -23
- package/dist/hoi4_agent_tools/core/workspace.js +84 -8
- package/dist/hoi4_agent_tools/decision/ai.js +1 -1
- package/dist/hoi4_agent_tools/decision/service.js +28 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
- package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
- package/dist/hoi4_agent_tools/event/operations.js +14 -1
- package/dist/hoi4_agent_tools/event/service.js +7 -0
- package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
- package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
- package/dist/hoi4_agent_tools/gui/studio.js +20 -7
- package/dist/hoi4_agent_tools/gui/validators.js +8 -6
- package/dist/hoi4_agent_tools/map/bmp.js +1 -1
- package/dist/hoi4_agent_tools/map/derived.js +748 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
- package/dist/hoi4_agent_tools/map/model.js +103 -0
- package/dist/hoi4_agent_tools/map/operations.js +427 -58
- package/dist/hoi4_agent_tools/map/references.js +483 -0
- package/dist/hoi4_agent_tools/map/service.js +136 -61
- package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
- package/dist/hoi4_agent_tools/map/validation.js +3 -0
- package/dist/hoi4_agent_tools/map/world.js +1280 -0
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
- package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
- package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
- package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
- package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
- package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
- package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
- package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
- package/dist/hoi4_agent_tools/probability/operations.js +175 -13
- package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
- package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
- package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
- package/dist/hoi4_agent_tools/reference/operations.js +88 -19
- package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
- package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
- package/dist/hoi4_agent_tools/reference/service.js +6 -0
- package/dist/hoi4_agent_tools/schemas/event.js +78 -19
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
- package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
- package/dist/hoi4_agent_tools/schemas/map.js +122 -3
- package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
- package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
- package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
- package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
- package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
- package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
- package/dist/hoi4_agent_tools/technology/operations.js +14 -1
- package/dist/hoi4_agent_tools/technology/service.js +7 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +7 -4
- package/docs/analysis.md +13 -0
- package/docs/configuration.md +281 -0
- package/docs/development.md +24 -2
- package/docs/events.md +27 -3
- package/docs/examples.md +6 -6
- package/docs/focus.md +125 -4
- package/docs/gui.md +98 -17
- package/docs/helper-expansion.md +8 -8
- package/docs/http.md +3 -1
- package/docs/jobs.md +19 -13
- package/docs/map.md +197 -6
- package/docs/mechanics.md +4 -0
- package/docs/probability.md +42 -15
- package/docs/reference.md +71 -4
- package/docs/setup.md +45 -11
- package/docs/technology.md +26 -1
- package/docs/third-party-notices.md +11 -0
- package/docs/tools.md +151 -42
- package/docs/troubleshooting.md +61 -0
- package/examples/clients/codex-config.toml +5 -2
- package/examples/clients/generic.json +2 -2
- package/examples/config/config.json +7 -0
- package/npm-shrinkwrap.json +161 -162
- package/package.json +13 -8
- package/schemas/configuration.schema.json +144 -128
- 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 +55 -0
- 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/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 +597 -126
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +51 -0
- package/schemas/operation-result.schema.json +2 -2
- 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/scenario-suite.schema.json +3 -5
- package/schemas/scenario-test-input.schema.json +3 -5
- 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 +3 -3
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map data derived from geometry rather than written by hand: building, unit and weather model
|
|
3
|
+
* positions, and the supply-node and railway network. The same planners serve the repair
|
|
4
|
+
* operations of an existing map and the generation of a new one; they return complete file
|
|
5
|
+
* contents and never touch source themselves.
|
|
6
|
+
*/
|
|
7
|
+
import { compareCodeUnits } from '../core/canonical.js';
|
|
8
|
+
import { ServiceError } from '../core/result.js';
|
|
9
|
+
import { derivedStateCapital, LEVEL_STATE_BUILDINGS, } from './model.js';
|
|
10
|
+
const TWO_PI = Math.PI * 2;
|
|
11
|
+
function fixed(value) {
|
|
12
|
+
return (Math.round(value * 100) / 100).toFixed(2);
|
|
13
|
+
}
|
|
14
|
+
/** A deterministic value in [0, 1) from integers, so repeated runs place models identically. */
|
|
15
|
+
function unitHash(...values) {
|
|
16
|
+
let hash = 2166136261;
|
|
17
|
+
for (const value of values) {
|
|
18
|
+
hash ^= value & 0xffffffff;
|
|
19
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
20
|
+
hash ^= hash >>> 13;
|
|
21
|
+
}
|
|
22
|
+
return (hash >>> 0) / 4294967296;
|
|
23
|
+
}
|
|
24
|
+
function mapRoot(index) {
|
|
25
|
+
return index.sourceRoots.map[0] ?? 'map';
|
|
26
|
+
}
|
|
27
|
+
function requireRaster(index) {
|
|
28
|
+
if (index.raster === undefined)
|
|
29
|
+
throw new ServiceError('MAP_PROVINCE_BITMAP_REQUIRED', 'Generated positions and supply need a readable province bitmap');
|
|
30
|
+
return index.raster;
|
|
31
|
+
}
|
|
32
|
+
function provinceShape(raster, id) {
|
|
33
|
+
const geometry = raster.geometry.get(id);
|
|
34
|
+
if (geometry === undefined || geometry.pixelCount === 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
const width = geometry.maxX - geometry.minX + 1;
|
|
37
|
+
const height = geometry.maxY - geometry.minY + 1;
|
|
38
|
+
const local = new Int32Array(width * height).fill(-1);
|
|
39
|
+
const xs = [];
|
|
40
|
+
const ys = [];
|
|
41
|
+
for (let y = geometry.minY; y <= geometry.maxY; y += 1)
|
|
42
|
+
for (let x = geometry.minX; x <= geometry.maxX; x += 1)
|
|
43
|
+
if (raster.provinceIds[y * raster.width + x] === id) {
|
|
44
|
+
local[(y - geometry.minY) * width + (x - geometry.minX)] = xs.length;
|
|
45
|
+
xs.push(x);
|
|
46
|
+
ys.push(y);
|
|
47
|
+
}
|
|
48
|
+
const count = xs.length;
|
|
49
|
+
const depth = new Int32Array(count).fill(-1);
|
|
50
|
+
const queue = new Int32Array(count);
|
|
51
|
+
let head = 0;
|
|
52
|
+
let tail = 0;
|
|
53
|
+
const neighbor = (pixel, dx, dy) => {
|
|
54
|
+
const x = xs[pixel] + dx - geometry.minX;
|
|
55
|
+
const y = ys[pixel] + dy - geometry.minY;
|
|
56
|
+
if (x < 0 || y < 0 || x >= width || y >= height)
|
|
57
|
+
return -1;
|
|
58
|
+
return local[y * width + x];
|
|
59
|
+
};
|
|
60
|
+
for (let pixel = 0; pixel < count; pixel += 1) {
|
|
61
|
+
if (neighbor(pixel, 1, 0) < 0 ||
|
|
62
|
+
neighbor(pixel, -1, 0) < 0 ||
|
|
63
|
+
neighbor(pixel, 0, 1) < 0 ||
|
|
64
|
+
neighbor(pixel, 0, -1) < 0) {
|
|
65
|
+
depth[pixel] = 0;
|
|
66
|
+
queue[tail++] = pixel;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
while (head < tail) {
|
|
70
|
+
const pixel = queue[head++];
|
|
71
|
+
for (const [dx, dy] of [
|
|
72
|
+
[1, 0],
|
|
73
|
+
[-1, 0],
|
|
74
|
+
[0, 1],
|
|
75
|
+
[0, -1],
|
|
76
|
+
]) {
|
|
77
|
+
const next = neighbor(pixel, dx, dy);
|
|
78
|
+
if (next >= 0 && depth[next] === -1) {
|
|
79
|
+
depth[next] = depth[pixel] + 1;
|
|
80
|
+
queue[tail++] = next;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let deepest = 0;
|
|
85
|
+
for (let pixel = 1; pixel < count; pixel += 1)
|
|
86
|
+
if (depth[pixel] > depth[deepest])
|
|
87
|
+
deepest = pixel;
|
|
88
|
+
return { id, xs: Int32Array.from(xs), ys: Int32Array.from(ys), depth, deepest };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Spread points inside a province: the deepest pixel first, then pixels far from the points
|
|
92
|
+
* already chosen while staying away from the border.
|
|
93
|
+
*/
|
|
94
|
+
function spreadPoints(shape, count) {
|
|
95
|
+
const total = shape.xs.length;
|
|
96
|
+
const stride = Math.max(1, Math.floor(total / 4000));
|
|
97
|
+
const candidates = [];
|
|
98
|
+
const minimumDepth = Math.min(2, shape.depth[shape.deepest]);
|
|
99
|
+
for (let pixel = 0; pixel < total; pixel += stride)
|
|
100
|
+
if (shape.depth[pixel] >= minimumDepth)
|
|
101
|
+
candidates.push(pixel);
|
|
102
|
+
if (candidates.length === 0)
|
|
103
|
+
candidates.push(shape.deepest);
|
|
104
|
+
const chosen = [shape.deepest];
|
|
105
|
+
while (chosen.length < count) {
|
|
106
|
+
let best = -1;
|
|
107
|
+
let bestScore = -1;
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
let nearest = Number.POSITIVE_INFINITY;
|
|
110
|
+
for (const point of chosen) {
|
|
111
|
+
const dx = shape.xs[candidate] - shape.xs[point];
|
|
112
|
+
const dy = shape.ys[candidate] - shape.ys[point];
|
|
113
|
+
nearest = Math.min(nearest, dx * dx + dy * dy);
|
|
114
|
+
}
|
|
115
|
+
const score = Math.min(Math.sqrt(nearest), 2 + shape.depth[candidate] * 3);
|
|
116
|
+
if (score > bestScore) {
|
|
117
|
+
bestScore = score;
|
|
118
|
+
best = candidate;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
chosen.push(best < 0 ? shape.deepest : best);
|
|
122
|
+
}
|
|
123
|
+
return chosen.map((pixel) => ({ x: shape.xs[pixel], y: shape.ys[pixel] }));
|
|
124
|
+
}
|
|
125
|
+
/** A coastal pixel of a land province facing the sea province it borders most. */
|
|
126
|
+
function coastPoint(raster, shape, isSea) {
|
|
127
|
+
const contacts = new Map();
|
|
128
|
+
for (let pixel = 0; pixel < shape.xs.length; pixel += 1) {
|
|
129
|
+
if (shape.depth[pixel] !== 0)
|
|
130
|
+
continue;
|
|
131
|
+
const x = shape.xs[pixel];
|
|
132
|
+
const y = shape.ys[pixel];
|
|
133
|
+
for (const [dx, dy] of [
|
|
134
|
+
[1, 0],
|
|
135
|
+
[-1, 0],
|
|
136
|
+
[0, 1],
|
|
137
|
+
[0, -1],
|
|
138
|
+
]) {
|
|
139
|
+
const nx = x + dx;
|
|
140
|
+
const ny = y + dy;
|
|
141
|
+
if (nx < 0 || ny < 0 || nx >= raster.width || ny >= raster.height)
|
|
142
|
+
continue;
|
|
143
|
+
const other = raster.provinceIds[ny * raster.width + nx];
|
|
144
|
+
if (other < 0 || !isSea(other))
|
|
145
|
+
continue;
|
|
146
|
+
const list = contacts.get(other) ?? [];
|
|
147
|
+
list.push({ x, y, dx, dy });
|
|
148
|
+
contacts.set(other, list);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const [sea, points] = [...contacts].sort(([leftId, left], [rightId, right]) => right.length - left.length || leftId - rightId)[0] ?? [];
|
|
152
|
+
if (sea === undefined || points === undefined)
|
|
153
|
+
return undefined;
|
|
154
|
+
const middle = points[Math.floor(points.length / 2)];
|
|
155
|
+
return { ...middle, sea };
|
|
156
|
+
}
|
|
157
|
+
function rotationFor(dx, dy) {
|
|
158
|
+
// Bitmap y grows downward; map z grows upward.
|
|
159
|
+
return Math.atan2(-dy, dx);
|
|
160
|
+
}
|
|
161
|
+
/** Which building models each state and province needs, from definitions or base-game defaults. */
|
|
162
|
+
function buildingSlots(index) {
|
|
163
|
+
const slots = [];
|
|
164
|
+
if (index.buildingDefinitions.size > 0) {
|
|
165
|
+
for (const definition of index.buildingDefinitions.values()) {
|
|
166
|
+
if (definition.spawnPoint !== undefined || definition.showOnMap <= 0)
|
|
167
|
+
continue;
|
|
168
|
+
slots.push({
|
|
169
|
+
name: definition.name,
|
|
170
|
+
perProvince: definition.provincial,
|
|
171
|
+
count: definition.showOnMap,
|
|
172
|
+
coastal: definition.onlyCoastal,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
for (const point of index.buildingSpawnPoints.values())
|
|
176
|
+
if (point.autoNudged)
|
|
177
|
+
slots.push({
|
|
178
|
+
name: point.name,
|
|
179
|
+
perProvince: point.provincial,
|
|
180
|
+
count: point.max,
|
|
181
|
+
coastal: point.onlyCoastal,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
for (const [name, count] of [
|
|
186
|
+
['arms_factory', 6],
|
|
187
|
+
['industrial_complex', 6],
|
|
188
|
+
['air_base', 1],
|
|
189
|
+
['anti_air_building', 3],
|
|
190
|
+
['synthetic_refinery', 1],
|
|
191
|
+
['fuel_silo', 1],
|
|
192
|
+
['radar_station', 1],
|
|
193
|
+
['nuclear_reactor_spawn', 1],
|
|
194
|
+
['rocket_site_spawn', 1],
|
|
195
|
+
])
|
|
196
|
+
slots.push({ name, perProvince: false, count, coastal: false });
|
|
197
|
+
slots.push({ name: 'dockyard', perProvince: false, count: 1, coastal: true });
|
|
198
|
+
for (const name of ['bunker', 'supply_node', 'special_project_facility_spawn'])
|
|
199
|
+
slots.push({ name, perProvince: true, count: 1, coastal: false });
|
|
200
|
+
for (const name of [
|
|
201
|
+
'coastal_bunker',
|
|
202
|
+
'naval_base_spawn',
|
|
203
|
+
'naval_headquarters',
|
|
204
|
+
'naval_supply_hub',
|
|
205
|
+
])
|
|
206
|
+
slots.push({ name, perProvince: true, count: 1, coastal: true });
|
|
207
|
+
}
|
|
208
|
+
// Floating harbours are placed like buildings although no definition declares them.
|
|
209
|
+
slots.push({ name: 'floating_harbor', perProvince: true, count: 1, coastal: true });
|
|
210
|
+
return slots.sort((left, right) => compareCodeUnits(left.name, right.name));
|
|
211
|
+
}
|
|
212
|
+
/** Rewrite a delimited data file: keep unselected lines, drop replaced ones, append new ones. */
|
|
213
|
+
function rewriteLines(document, drop, added) {
|
|
214
|
+
const newline = document?.newline ?? '\n';
|
|
215
|
+
const kept = document === undefined
|
|
216
|
+
? []
|
|
217
|
+
: document.lines
|
|
218
|
+
.filter((line) => !drop.has(line.index + 1))
|
|
219
|
+
.map((line) => line.text)
|
|
220
|
+
.filter((text, position, all) => !(position === all.length - 1 && text === ''));
|
|
221
|
+
return Buffer.from(`${[...kept, ...added].join(newline)}${newline}`, 'utf8');
|
|
222
|
+
}
|
|
223
|
+
function selectedStates(index, request) {
|
|
224
|
+
const explicit = request.stateIds !== undefined ||
|
|
225
|
+
request.provinceIds !== undefined ||
|
|
226
|
+
request.regionIds !== undefined;
|
|
227
|
+
if (!explicit)
|
|
228
|
+
return index.states;
|
|
229
|
+
const ids = new Set(request.stateIds ?? []);
|
|
230
|
+
for (const provinceId of request.provinceIds ?? [])
|
|
231
|
+
for (const state of index.statesByProvince.get(provinceId) ?? [])
|
|
232
|
+
ids.add(state.id);
|
|
233
|
+
return index.states.filter(({ id }) => ids.has(id));
|
|
234
|
+
}
|
|
235
|
+
function selectedProvinces(index, request) {
|
|
236
|
+
const explicit = request.stateIds !== undefined ||
|
|
237
|
+
request.provinceIds !== undefined ||
|
|
238
|
+
request.regionIds !== undefined;
|
|
239
|
+
const ids = new Set();
|
|
240
|
+
if (!explicit) {
|
|
241
|
+
for (const definition of index.definitions)
|
|
242
|
+
if (definition.id > 0)
|
|
243
|
+
ids.add(definition.id);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
for (const id of request.provinceIds ?? [])
|
|
247
|
+
ids.add(id);
|
|
248
|
+
for (const stateId of request.stateIds ?? [])
|
|
249
|
+
for (const id of index.statesById.get(stateId)?.provinces ?? [])
|
|
250
|
+
ids.add(id);
|
|
251
|
+
for (const regionId of request.regionIds ?? [])
|
|
252
|
+
for (const id of index.regionsById.get(regionId)?.provinces ?? [])
|
|
253
|
+
ids.add(id);
|
|
254
|
+
}
|
|
255
|
+
return [...ids].filter((id) => index.raster?.geometry.has(id) === true).sort((a, b) => a - b);
|
|
256
|
+
}
|
|
257
|
+
function isSeaProvince(index, id) {
|
|
258
|
+
return index.definitionsById.get(id)?.type === 'sea';
|
|
259
|
+
}
|
|
260
|
+
function planBuildingPositions(index, request) {
|
|
261
|
+
const raster = requireRaster(index);
|
|
262
|
+
const replace = request.mode === 'replace';
|
|
263
|
+
const states = selectedStates(index, request);
|
|
264
|
+
const stateIds = new Set(states.map(({ id }) => id));
|
|
265
|
+
const existing = new Map();
|
|
266
|
+
for (const record of index.buildingPositions)
|
|
267
|
+
existing.set(`${record.stateId};${record.building}`, (existing.get(`${record.stateId};${record.building}`) ?? 0) + 1);
|
|
268
|
+
const document = index.buildingPositions[0]?.document;
|
|
269
|
+
const drop = new Set();
|
|
270
|
+
if (replace)
|
|
271
|
+
for (const record of index.buildingPositions)
|
|
272
|
+
if (stateIds.has(record.stateId))
|
|
273
|
+
drop.add(record.line);
|
|
274
|
+
const slots = buildingSlots(index);
|
|
275
|
+
const shapes = new Map();
|
|
276
|
+
const shapeOf = (id) => {
|
|
277
|
+
if (!shapes.has(id))
|
|
278
|
+
shapes.set(id, provinceShape(raster, id));
|
|
279
|
+
return shapes.get(id);
|
|
280
|
+
};
|
|
281
|
+
const isSea = (id) => isSeaProvince(index, id);
|
|
282
|
+
const added = [];
|
|
283
|
+
for (const state of [...states].sort((left, right) => left.id - right.id)) {
|
|
284
|
+
const provinces = state.provinces
|
|
285
|
+
.filter((id) => !isSea(id) && raster.geometry.has(id))
|
|
286
|
+
.sort((left, right) => (raster.geometry.get(right)?.pixelCount ?? 0) -
|
|
287
|
+
(raster.geometry.get(left)?.pixelCount ?? 0) || left - right);
|
|
288
|
+
if (provinces.length === 0)
|
|
289
|
+
continue;
|
|
290
|
+
const coastal = provinces.filter((id) => index.coastalProvinceIds.has(id));
|
|
291
|
+
const capital = state.capital ?? derivedStateCapital(state.victoryPoints) ?? provinces[0];
|
|
292
|
+
for (const slot of slots) {
|
|
293
|
+
const owners = slot.perProvince
|
|
294
|
+
? slot.coastal
|
|
295
|
+
? coastal
|
|
296
|
+
: provinces
|
|
297
|
+
: [slot.coastal ? coastal[0] : capital];
|
|
298
|
+
if (!slot.perProvince && slot.coastal && coastal.length === 0)
|
|
299
|
+
continue;
|
|
300
|
+
const have = replace ? 0 : (existing.get(`${state.id};${slot.name}`) ?? 0);
|
|
301
|
+
const wanted = slot.count * owners.length;
|
|
302
|
+
if (have >= wanted)
|
|
303
|
+
continue;
|
|
304
|
+
let produced = 0;
|
|
305
|
+
for (const provinceId of owners) {
|
|
306
|
+
if (provinceId === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
const shape = shapeOf(provinceId);
|
|
309
|
+
if (shape === undefined)
|
|
310
|
+
continue;
|
|
311
|
+
const perOwner = slot.count;
|
|
312
|
+
if (slot.coastal) {
|
|
313
|
+
const coast = coastPoint(raster, shape, isSea);
|
|
314
|
+
if (coast === undefined)
|
|
315
|
+
continue;
|
|
316
|
+
for (let copy = 0; copy < perOwner && produced < wanted - have; copy += 1, produced += 1) {
|
|
317
|
+
const floating = slot.name === 'floating_harbor';
|
|
318
|
+
// A floating harbour sits on the water two pixels out and names its land province.
|
|
319
|
+
const x = floating ? coast.x + coast.dx * 2 : coast.x;
|
|
320
|
+
const y = floating ? coast.y + coast.dy * 2 : coast.y;
|
|
321
|
+
added.push([
|
|
322
|
+
state.id,
|
|
323
|
+
slot.name,
|
|
324
|
+
fixed(x),
|
|
325
|
+
fixed(floating ? 9.5 : index.heightAt(x, y)),
|
|
326
|
+
fixed(raster.height - 1 - y),
|
|
327
|
+
fixed(rotationFor(coast.dx, coast.dy)),
|
|
328
|
+
floating ? provinceId : slot.name.startsWith('naval_base') ? coast.sea : 0,
|
|
329
|
+
].join(';'));
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const points = spreadPoints(shape, perOwner);
|
|
334
|
+
for (const [copy, point] of points.entries()) {
|
|
335
|
+
if (produced >= wanted - have)
|
|
336
|
+
break;
|
|
337
|
+
added.push([
|
|
338
|
+
state.id,
|
|
339
|
+
slot.name,
|
|
340
|
+
fixed(point.x),
|
|
341
|
+
fixed(index.heightAt(point.x, point.y)),
|
|
342
|
+
fixed(raster.height - 1 - point.y),
|
|
343
|
+
fixed(unitHash(state.id, provinceId, copy, slot.name.length) * TWO_PI),
|
|
344
|
+
0,
|
|
345
|
+
].join(';'));
|
|
346
|
+
produced += 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
relativePath: document?.file.relativePath ?? `${mapRoot(index)}/buildings.txt`,
|
|
353
|
+
content: rewriteLines(document, drop, added),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function planUnitPositions(index, request) {
|
|
357
|
+
const raster = requireRaster(index);
|
|
358
|
+
const replace = request.mode === 'replace';
|
|
359
|
+
const provinces = selectedProvinces(index, request);
|
|
360
|
+
const selected = new Set(provinces);
|
|
361
|
+
const covered = new Set(index.unitPositions.map(({ provinceId }) => provinceId));
|
|
362
|
+
const document = index.unitPositions[0]?.document;
|
|
363
|
+
const drop = new Set();
|
|
364
|
+
if (replace)
|
|
365
|
+
for (const record of index.unitPositions)
|
|
366
|
+
if (selected.has(record.provinceId))
|
|
367
|
+
drop.add(record.line);
|
|
368
|
+
const added = [];
|
|
369
|
+
for (const id of provinces) {
|
|
370
|
+
if (!replace && covered.has(id))
|
|
371
|
+
continue;
|
|
372
|
+
const definition = index.definitionsById.get(id);
|
|
373
|
+
if (definition === undefined || definition.type === 'lake')
|
|
374
|
+
continue;
|
|
375
|
+
const shape = provinceShape(raster, id);
|
|
376
|
+
if (shape === undefined)
|
|
377
|
+
continue;
|
|
378
|
+
const sea = definition.type === 'sea';
|
|
379
|
+
const cx = shape.xs[shape.deepest];
|
|
380
|
+
const cy = shape.ys[shape.deepest];
|
|
381
|
+
const reach = Math.max(1, Math.min(6, shape.depth[shape.deepest] * 0.6));
|
|
382
|
+
const neighbors = [...(raster.adjacency.get(id) ?? [])].sort((left, right) => left - right);
|
|
383
|
+
const toward = (other) => {
|
|
384
|
+
const geometry = raster.geometry.get(other);
|
|
385
|
+
const dx = geometry.centerX - cx;
|
|
386
|
+
const dy = geometry.centerY - cy;
|
|
387
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
388
|
+
return { dx: dx / length, dy: dy / length };
|
|
389
|
+
};
|
|
390
|
+
const offset = fixed(0.2 + unitHash(id) * 1.0);
|
|
391
|
+
const line = (type, x, y, rotation) => [
|
|
392
|
+
id,
|
|
393
|
+
type,
|
|
394
|
+
fixed(x),
|
|
395
|
+
fixed(sea ? 9.5 : index.heightAt(x, y)),
|
|
396
|
+
fixed(raster.height - 1 - y),
|
|
397
|
+
fixed(rotation),
|
|
398
|
+
offset,
|
|
399
|
+
].join(';');
|
|
400
|
+
const moving = neighbors.slice(0, 8);
|
|
401
|
+
const landing = neighbors
|
|
402
|
+
.filter((other) => (index.definitionsById.get(other)?.type === 'sea') !== sea)
|
|
403
|
+
.slice(0, 8);
|
|
404
|
+
// Step from the centre toward a direction and stop before leaving the province, so every
|
|
405
|
+
// model stands on its own province even in narrow shapes.
|
|
406
|
+
const place = (type, dx, dy, distance, rotation) => {
|
|
407
|
+
let x = cx;
|
|
408
|
+
let y = cy;
|
|
409
|
+
for (let travelled = 0.5; travelled <= distance; travelled += 0.5) {
|
|
410
|
+
const px = Math.floor(cx + dx * travelled);
|
|
411
|
+
const py = Math.floor(cy + dy * travelled);
|
|
412
|
+
if (px < 0 || py < 0 || px >= raster.width || py >= raster.height)
|
|
413
|
+
break;
|
|
414
|
+
if (raster.provinceIds[py * raster.width + px] !== id)
|
|
415
|
+
break;
|
|
416
|
+
// Whole pixels: the game and the validator read a position by the pixel it falls in.
|
|
417
|
+
x = px;
|
|
418
|
+
y = py;
|
|
419
|
+
}
|
|
420
|
+
added.push(line(type, x, y, rotation));
|
|
421
|
+
};
|
|
422
|
+
added.push(line(0, cx, cy, 0));
|
|
423
|
+
for (const [position, other] of moving.entries()) {
|
|
424
|
+
const { dx, dy } = toward(other);
|
|
425
|
+
place(1 + position, dx, dy, reach, rotationFor(dx, dy));
|
|
426
|
+
}
|
|
427
|
+
const first = moving[0] === undefined ? { dx: 1, dy: 0 } : toward(moving[0]);
|
|
428
|
+
place(9, -first.dx, -first.dy, reach * 0.5, rotationFor(first.dx, first.dy));
|
|
429
|
+
place(10, first.dx, first.dy, reach * 0.5, rotationFor(-first.dx, -first.dy));
|
|
430
|
+
for (const [position, other] of landing.entries()) {
|
|
431
|
+
const { dx, dy } = toward(other);
|
|
432
|
+
place(11 + position, dx, dy, reach, rotationFor(dx, dy));
|
|
433
|
+
}
|
|
434
|
+
added.push(line(21, cx, cy, 0));
|
|
435
|
+
for (const [position, other] of moving.entries()) {
|
|
436
|
+
const { dx, dy } = toward(other);
|
|
437
|
+
place(22 + position, dx, dy, reach, rotationFor(dx, dy));
|
|
438
|
+
}
|
|
439
|
+
for (const [position, other] of landing.entries()) {
|
|
440
|
+
const { dx, dy } = toward(other);
|
|
441
|
+
place(30 + position, dx, dy, reach, rotationFor(dx, dy));
|
|
442
|
+
}
|
|
443
|
+
place(38, -first.dx, -first.dy, reach * 0.5, rotationFor(first.dx, first.dy));
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
relativePath: document?.file.relativePath ?? `${mapRoot(index)}/unitstacks.txt`,
|
|
447
|
+
content: rewriteLines(document, drop, added),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function planWeatherPositions(index, request) {
|
|
451
|
+
const raster = requireRaster(index);
|
|
452
|
+
const replace = request.mode === 'replace';
|
|
453
|
+
const explicitRegions = request.regionIds !== undefined;
|
|
454
|
+
const regions = index.regions.filter(({ id }) => !explicitRegions || request.regionIds.includes(id));
|
|
455
|
+
const selected = new Set(regions.map(({ id }) => id));
|
|
456
|
+
const covered = new Set(index.weatherPositions.map(({ strategicRegionId }) => strategicRegionId));
|
|
457
|
+
const document = index.weatherPositions[0]?.document;
|
|
458
|
+
const drop = new Set();
|
|
459
|
+
if (replace)
|
|
460
|
+
for (const record of index.weatherPositions)
|
|
461
|
+
if (selected.has(record.strategicRegionId))
|
|
462
|
+
drop.add(record.line);
|
|
463
|
+
const added = [];
|
|
464
|
+
for (const region of [...regions].sort((left, right) => left.id - right.id)) {
|
|
465
|
+
if (!replace && covered.has(region.id))
|
|
466
|
+
continue;
|
|
467
|
+
let weight = 0;
|
|
468
|
+
let sumX = 0;
|
|
469
|
+
let sumY = 0;
|
|
470
|
+
for (const id of region.provinces) {
|
|
471
|
+
const geometry = raster.geometry.get(id);
|
|
472
|
+
if (geometry === undefined)
|
|
473
|
+
continue;
|
|
474
|
+
weight += geometry.pixelCount;
|
|
475
|
+
sumX += geometry.centerX * geometry.pixelCount;
|
|
476
|
+
sumY += geometry.centerY * geometry.pixelCount;
|
|
477
|
+
}
|
|
478
|
+
if (weight === 0)
|
|
479
|
+
continue;
|
|
480
|
+
const meanX = sumX / weight;
|
|
481
|
+
const meanY = sumY / weight;
|
|
482
|
+
const anchor = region.provinces
|
|
483
|
+
.filter((id) => raster.geometry.has(id))
|
|
484
|
+
.sort((left, right) => {
|
|
485
|
+
const a = raster.geometry.get(left);
|
|
486
|
+
const b = raster.geometry.get(right);
|
|
487
|
+
return (Math.hypot(a.centerX - meanX, a.centerY - meanY) -
|
|
488
|
+
Math.hypot(b.centerX - meanX, b.centerY - meanY) || left - right);
|
|
489
|
+
})[0];
|
|
490
|
+
const shape = anchor === undefined ? undefined : provinceShape(raster, anchor);
|
|
491
|
+
if (shape === undefined)
|
|
492
|
+
continue;
|
|
493
|
+
const [small, large] = spreadPoints(shape, 2);
|
|
494
|
+
for (const [point, size] of [
|
|
495
|
+
[small, 'small'],
|
|
496
|
+
[large ?? small, 'big'],
|
|
497
|
+
])
|
|
498
|
+
added.push([
|
|
499
|
+
region.id,
|
|
500
|
+
fixed(point.x),
|
|
501
|
+
fixed(index.heightAt(point.x, point.y) + 0.4),
|
|
502
|
+
fixed(raster.height - 1 - point.y),
|
|
503
|
+
size,
|
|
504
|
+
].join(';'));
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
relativePath: document?.file.relativePath ?? `${mapRoot(index)}/weatherpositions.txt`,
|
|
508
|
+
content: rewriteLines(document, drop, added),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
/** Building, unit-stack and weather positions for a selection, or for the whole map. */
|
|
512
|
+
export function planMapPositions(index, request) {
|
|
513
|
+
const kinds = new Set(request.positions ?? ['buildings', 'units', 'weather']);
|
|
514
|
+
return [
|
|
515
|
+
...(kinds.has('buildings') ? [planBuildingPositions(index, request)] : []),
|
|
516
|
+
...(kinds.has('units') ? [planUnitPositions(index, request)] : []),
|
|
517
|
+
...(kinds.has('weather') ? [planWeatherPositions(index, request)] : []),
|
|
518
|
+
];
|
|
519
|
+
}
|
|
520
|
+
/** Land provinces that belong to a state: the only ones a railway may cross. */
|
|
521
|
+
function railNeighbors(index) {
|
|
522
|
+
const raster = requireRaster(index);
|
|
523
|
+
const blocked = new Set(index.adjacencies
|
|
524
|
+
.filter(({ type }) => type === 'impassable')
|
|
525
|
+
.flatMap(({ from, to }) => [`${from}:${to}`, `${to}:${from}`]));
|
|
526
|
+
const usable = (id) => index.definitionsById.get(id)?.type === 'land' &&
|
|
527
|
+
(index.statesByProvince.get(id)?.length ?? 0) > 0;
|
|
528
|
+
return (id) => [...(raster.adjacency.get(id) ?? [])]
|
|
529
|
+
.filter((other) => usable(other) && !blocked.has(`${id}:${other}`))
|
|
530
|
+
.sort((left, right) => left - right);
|
|
531
|
+
}
|
|
532
|
+
/** The province a state's supply node belongs in: its capital, else its largest land province. */
|
|
533
|
+
function stateHubProvince(index, state) {
|
|
534
|
+
const land = state.provinces.filter((id) => index.definitionsById.get(id)?.type === 'land');
|
|
535
|
+
const capital = state.capital ?? derivedStateCapital(state.victoryPoints);
|
|
536
|
+
if (capital !== undefined && land.includes(capital))
|
|
537
|
+
return capital;
|
|
538
|
+
return land.sort((left, right) => (index.raster?.geometry.get(right)?.pixelCount ?? 0) -
|
|
539
|
+
(index.raster?.geometry.get(left)?.pixelCount ?? 0) || left - right)[0];
|
|
540
|
+
}
|
|
541
|
+
/** A state with a supply node within this many land provinces has no supply gap. */
|
|
542
|
+
const SUPPLY_GAP_PROVINCES = 3;
|
|
543
|
+
/** A binary min-heap of [cost, province] pairs. */
|
|
544
|
+
class CostHeap {
|
|
545
|
+
#items = [];
|
|
546
|
+
get size() {
|
|
547
|
+
return this.#items.length;
|
|
548
|
+
}
|
|
549
|
+
push(item) {
|
|
550
|
+
const items = this.#items;
|
|
551
|
+
items.push(item);
|
|
552
|
+
let index = items.length - 1;
|
|
553
|
+
while (index > 0) {
|
|
554
|
+
const parent = (index - 1) >> 1;
|
|
555
|
+
if (CostHeap.before(items[parent], items[index]))
|
|
556
|
+
break;
|
|
557
|
+
[items[parent], items[index]] = [items[index], items[parent]];
|
|
558
|
+
index = parent;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
pop() {
|
|
562
|
+
const items = this.#items;
|
|
563
|
+
const top = items[0];
|
|
564
|
+
const last = items.pop();
|
|
565
|
+
if (items.length > 0) {
|
|
566
|
+
items[0] = last;
|
|
567
|
+
let index = 0;
|
|
568
|
+
for (;;) {
|
|
569
|
+
const left = index * 2 + 1;
|
|
570
|
+
const right = left + 1;
|
|
571
|
+
let smallest = index;
|
|
572
|
+
if (left < items.length && CostHeap.before(items[left], items[smallest]))
|
|
573
|
+
smallest = left;
|
|
574
|
+
if (right < items.length && CostHeap.before(items[right], items[smallest]))
|
|
575
|
+
smallest = right;
|
|
576
|
+
if (smallest === index)
|
|
577
|
+
break;
|
|
578
|
+
[items[smallest], items[index]] = [items[index], items[smallest]];
|
|
579
|
+
index = smallest;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return top;
|
|
583
|
+
}
|
|
584
|
+
static before(left, right) {
|
|
585
|
+
return left[0] < right[0] || (left[0] === right[0] && left[1] < right[1]);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** Cheapest path through land provinces, preferring the owner's own territory. */
|
|
589
|
+
function shortestRailPath(neighbors, sources, isTarget, cost) {
|
|
590
|
+
const distance = new Map();
|
|
591
|
+
const previous = new Map();
|
|
592
|
+
const frontier = new CostHeap();
|
|
593
|
+
for (const source of sources) {
|
|
594
|
+
distance.set(source, 0);
|
|
595
|
+
frontier.push([0, source]);
|
|
596
|
+
}
|
|
597
|
+
while (frontier.size > 0) {
|
|
598
|
+
const [current, province] = frontier.pop();
|
|
599
|
+
if (current > (distance.get(province) ?? Number.POSITIVE_INFINITY))
|
|
600
|
+
continue;
|
|
601
|
+
if (!sources.has(province) && isTarget(province)) {
|
|
602
|
+
const path = [province];
|
|
603
|
+
let cursor = province;
|
|
604
|
+
while (previous.has(cursor)) {
|
|
605
|
+
cursor = previous.get(cursor);
|
|
606
|
+
path.push(cursor);
|
|
607
|
+
}
|
|
608
|
+
return path;
|
|
609
|
+
}
|
|
610
|
+
for (const next of neighbors(province)) {
|
|
611
|
+
const candidate = current + cost(next);
|
|
612
|
+
if (candidate < (distance.get(next) ?? Number.POSITIVE_INFINITY)) {
|
|
613
|
+
distance.set(next, candidate);
|
|
614
|
+
previous.set(next, province);
|
|
615
|
+
frontier.push([candidate, next]);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Supply nodes and railways: every owned state gets a node in its capital province, and each
|
|
623
|
+
* country's nodes are joined to its main hub (the state with the most victory points) by the
|
|
624
|
+
* shortest railway through its own land where possible.
|
|
625
|
+
*/
|
|
626
|
+
export function planSupplyRebuild(index, request) {
|
|
627
|
+
requireRaster(index);
|
|
628
|
+
const replace = request.mode === 'replace';
|
|
629
|
+
const railwayLevel = request.railwayLevel ?? 1;
|
|
630
|
+
const capitalLevel = request.capitalRailwayLevel ?? 3;
|
|
631
|
+
const neighbors = railNeighbors(index);
|
|
632
|
+
const owned = index.states.filter((state) => index.ownersByState.has(state.id));
|
|
633
|
+
const scope = request.stateIds === undefined
|
|
634
|
+
? owned
|
|
635
|
+
: owned.filter(({ id }) => request.stateIds.includes(id));
|
|
636
|
+
const nodes = new Set(replace ? [] : index.supplyNodes.map(({ provinceId }) => provinceId));
|
|
637
|
+
const railways = replace ? [] : index.railways.map(({ provinces }) => [...provinces]);
|
|
638
|
+
const newNodes = [];
|
|
639
|
+
const newRailways = [];
|
|
640
|
+
const byOwner = new Map();
|
|
641
|
+
for (const state of owned) {
|
|
642
|
+
const owner = index.ownersByState.get(state.id);
|
|
643
|
+
const list = byOwner.get(owner) ?? [];
|
|
644
|
+
list.push(state);
|
|
645
|
+
byOwner.set(owner, list);
|
|
646
|
+
}
|
|
647
|
+
const ownerOf = (province) => {
|
|
648
|
+
const state = index.statesByProvince.get(province)?.[0];
|
|
649
|
+
return state === undefined ? undefined : index.ownersByState.get(state.id);
|
|
650
|
+
};
|
|
651
|
+
// Without named states, connect only states with a supply gap: no node in the state or on
|
|
652
|
+
// its border. Named states always get their own node.
|
|
653
|
+
// Supply reaches a few provinces past a node; a state within that reach has no gap.
|
|
654
|
+
const reach = new Map();
|
|
655
|
+
const queue = [...nodes];
|
|
656
|
+
for (const id of queue)
|
|
657
|
+
reach.set(id, 0);
|
|
658
|
+
// Iterating the queue while appending to it visits the appended provinces too.
|
|
659
|
+
for (const current of queue) {
|
|
660
|
+
const distance = reach.get(current);
|
|
661
|
+
if (distance >= SUPPLY_GAP_PROVINCES)
|
|
662
|
+
continue;
|
|
663
|
+
for (const next of neighbors(current))
|
|
664
|
+
if (!reach.has(next)) {
|
|
665
|
+
reach.set(next, distance + 1);
|
|
666
|
+
queue.push(next);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const nearNode = (state) => state.provinces.some((id) => reach.has(id));
|
|
670
|
+
for (const state of scope) {
|
|
671
|
+
if (state.provinces.some((id) => nodes.has(id)))
|
|
672
|
+
continue;
|
|
673
|
+
if (!replace && request.stateIds === undefined && nearNode(state))
|
|
674
|
+
continue;
|
|
675
|
+
const hub = stateHubProvince(index, state);
|
|
676
|
+
if (hub === undefined)
|
|
677
|
+
continue;
|
|
678
|
+
nodes.add(hub);
|
|
679
|
+
newNodes.push(hub);
|
|
680
|
+
}
|
|
681
|
+
for (const [owner, states] of [...byOwner].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
682
|
+
const ownerScope = states.filter((state) => scope.includes(state));
|
|
683
|
+
if (ownerScope.length === 0)
|
|
684
|
+
continue;
|
|
685
|
+
const hubState = [...states].sort((left, right) => right.victoryPoints.reduce((sum, { value }) => sum + value, 0) -
|
|
686
|
+
left.victoryPoints.reduce((sum, { value }) => sum + value, 0) ||
|
|
687
|
+
right.provinces.length - left.provinces.length ||
|
|
688
|
+
left.id - right.id)[0];
|
|
689
|
+
const hub = hubState.provinces.find((id) => nodes.has(id)) ?? stateHubProvince(index, hubState);
|
|
690
|
+
if (hub === undefined)
|
|
691
|
+
continue;
|
|
692
|
+
// The hub's railway component.
|
|
693
|
+
const adjacency = new Map();
|
|
694
|
+
for (const path of railways)
|
|
695
|
+
for (let step = 1; step < path.length; step += 1) {
|
|
696
|
+
const left = path[step - 1];
|
|
697
|
+
const right = path[step];
|
|
698
|
+
adjacency.set(left, (adjacency.get(left) ?? new Set()).add(right));
|
|
699
|
+
adjacency.set(right, (adjacency.get(right) ?? new Set()).add(left));
|
|
700
|
+
}
|
|
701
|
+
const connected = new Set([hub]);
|
|
702
|
+
const queue = [hub];
|
|
703
|
+
while (queue.length > 0) {
|
|
704
|
+
const province = queue.shift();
|
|
705
|
+
for (const next of adjacency.get(province) ?? [])
|
|
706
|
+
if (!connected.has(next)) {
|
|
707
|
+
connected.add(next);
|
|
708
|
+
queue.push(next);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const pending = new Set(ownerScope
|
|
712
|
+
.map((state) => state.provinces.find((id) => nodes.has(id)))
|
|
713
|
+
.filter((id) => id !== undefined && !connected.has(id)));
|
|
714
|
+
while (pending.size > 0) {
|
|
715
|
+
const path = shortestRailPath(neighbors, connected, (id) => pending.has(id), (id) => (ownerOf(id) === owner ? 1 : 4));
|
|
716
|
+
if (path === undefined)
|
|
717
|
+
break;
|
|
718
|
+
// A path that leaves the connected set once: from its target back to the network.
|
|
719
|
+
const level = path.includes(hub) ? capitalLevel : railwayLevel;
|
|
720
|
+
newRailways.push({ level, provinces: [...path].reverse() });
|
|
721
|
+
railways.push(path);
|
|
722
|
+
for (const id of path) {
|
|
723
|
+
connected.add(id);
|
|
724
|
+
pending.delete(id);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const nodeDocument = index.supplyNodes[0]?.document;
|
|
729
|
+
const railDocument = index.railways[0]?.document;
|
|
730
|
+
const nodeDrop = new Set(replace ? index.supplyNodes.map(({ line }) => line) : []);
|
|
731
|
+
const railDrop = new Set(replace ? index.railways.map(({ line }) => line) : []);
|
|
732
|
+
return [
|
|
733
|
+
{
|
|
734
|
+
relativePath: index.supplyNodeFile?.relativePath ??
|
|
735
|
+
nodeDocument?.file.relativePath ??
|
|
736
|
+
`${mapRoot(index)}/supply_nodes.txt`,
|
|
737
|
+
content: rewriteLines(nodeDocument, nodeDrop, newNodes.map((id) => `1 ${id}`)),
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
relativePath: index.railwayFile?.relativePath ??
|
|
741
|
+
railDocument?.file.relativePath ??
|
|
742
|
+
`${mapRoot(index)}/railways.txt`,
|
|
743
|
+
content: rewriteLines(railDocument, railDrop, newRailways.map(({ level, provinces }) => `${level} ${provinces.length} ${provinces.join(' ')}`)),
|
|
744
|
+
},
|
|
745
|
+
];
|
|
746
|
+
}
|
|
747
|
+
/** Base-game level buildings, re-exported for callers that classify buildings without an index. */
|
|
748
|
+
export { LEVEL_STATE_BUILDINGS };
|