hoi4-agent-tools 4.5.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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/terrain-synthesis.js +43 -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/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 +98 -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,944 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
import { BORDER_PROVINCE_KIND, buildBorderEdgeTableSteps, scoreBorderAssignment, } from './border-quality.js';
|
|
3
|
+
import { borderQualityInputFromIndex } from './border-quality-input.js';
|
|
4
|
+
import { cloneState, commitState, currentIndex, uniqueStrings, } from './operations.js';
|
|
5
|
+
import { clusterProvinces } from './partition-groups.js';
|
|
6
|
+
import { restructureStates } from './state-restructure.js';
|
|
7
|
+
import { buildTerritoryAtoms, buildTerritoryProvinceGraph, } from './territory-graph.js';
|
|
8
|
+
import { growTerritory, TERRITORY_MAX_COUNTRIES, } from './territory.js';
|
|
9
|
+
/** A bounded summary of a report for tool results; the complete report is an artifact. */
|
|
10
|
+
export function compactTerritoryReport(report) {
|
|
11
|
+
const stats = report.stats;
|
|
12
|
+
return {
|
|
13
|
+
operationId: report.operationId,
|
|
14
|
+
kind: report.kind,
|
|
15
|
+
unit: report.unit,
|
|
16
|
+
atoms: report.atoms,
|
|
17
|
+
barriers: report.barriers,
|
|
18
|
+
...(stats === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: {
|
|
21
|
+
sizes: {
|
|
22
|
+
countries: stats.countries.length,
|
|
23
|
+
assignedAtoms: stats.assignedAtoms,
|
|
24
|
+
unownedAtoms: stats.unownedAtoms,
|
|
25
|
+
maxShareError: stats.maxShareError,
|
|
26
|
+
rounds: stats.rounds,
|
|
27
|
+
smoothingMoves: stats.smoothingMoves,
|
|
28
|
+
corridors: stats.corridors,
|
|
29
|
+
worst: stats.countries
|
|
30
|
+
.slice()
|
|
31
|
+
.sort((a, b) => b.shareError - a.shareError || (a.tag < b.tag ? -1 : 1))
|
|
32
|
+
.slice(0, 8)
|
|
33
|
+
.map(({ tag, atoms, share, targetShare, shareError }) => ({
|
|
34
|
+
tag,
|
|
35
|
+
atoms,
|
|
36
|
+
share,
|
|
37
|
+
targetShare,
|
|
38
|
+
shareError,
|
|
39
|
+
})),
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
border: {
|
|
43
|
+
edges: report.score.borderEdges,
|
|
44
|
+
alignment: report.score.alignment,
|
|
45
|
+
baseRate: report.score.baseRate,
|
|
46
|
+
enrichment: report.score.enrichment,
|
|
47
|
+
naturalShare: report.score.naturalShare,
|
|
48
|
+
...(report.baseline === undefined
|
|
49
|
+
? {}
|
|
50
|
+
: {
|
|
51
|
+
distanceOnly: {
|
|
52
|
+
enrichment: report.baseline.enrichment,
|
|
53
|
+
naturalShare: report.baseline.naturalShare,
|
|
54
|
+
maxShareError: report.baseline.maxShareError,
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
...(report.before === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: {
|
|
60
|
+
before: {
|
|
61
|
+
enrichment: report.before.enrichment,
|
|
62
|
+
naturalShare: report.before.naturalShare,
|
|
63
|
+
edges: report.before.borderEdges,
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
states: {
|
|
68
|
+
changed: report.states.changed.length,
|
|
69
|
+
created: report.states.created.length,
|
|
70
|
+
removed: report.states.removed.length,
|
|
71
|
+
ownershipChanged: report.states.ownershipChanged,
|
|
72
|
+
},
|
|
73
|
+
borderQuality: report.borderQuality,
|
|
74
|
+
notes: report.notes.slice(0, 5).map(({ code, message }) => ({ code, message })),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function refuse(code, message, details = {}) {
|
|
78
|
+
throw new ServiceError(code, message, details);
|
|
79
|
+
}
|
|
80
|
+
function* buildTerritoryWorld(index, signal) {
|
|
81
|
+
if (index.raster === undefined)
|
|
82
|
+
refuse('MAP_TERRITORY_MAP_MISSING', 'Territory assignment needs a valid province bitmap and province definitions');
|
|
83
|
+
const { input, skipped } = borderQualityInputFromIndex(index);
|
|
84
|
+
const table = yield* buildBorderEdgeTableSteps(input, signal === undefined ? {} : { signal });
|
|
85
|
+
yield;
|
|
86
|
+
return { index, table, graph: buildTerritoryProvinceGraph(table), skipped };
|
|
87
|
+
}
|
|
88
|
+
const LAND = BORDER_PROVINCE_KIND.land;
|
|
89
|
+
function isLandProvince(table, province) {
|
|
90
|
+
return (Number.isInteger(province) &&
|
|
91
|
+
province >= 0 &&
|
|
92
|
+
province < table.provinceKinds.length &&
|
|
93
|
+
table.provinceKinds[province] === LAND);
|
|
94
|
+
}
|
|
95
|
+
/** The strategic region holding most of a group of provinces (lowest id on a tie), -1 for none. */
|
|
96
|
+
function dominantRegion(index, provinces) {
|
|
97
|
+
const counts = new Map();
|
|
98
|
+
for (const province of provinces) {
|
|
99
|
+
const id = index.regionForProvince(province)[0]?.id ?? -1;
|
|
100
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
101
|
+
}
|
|
102
|
+
let best = -1;
|
|
103
|
+
let bestCount = 0;
|
|
104
|
+
for (const [id, count] of [...counts].sort(([a], [b]) => a - b))
|
|
105
|
+
if (count > bestCount) {
|
|
106
|
+
best = id;
|
|
107
|
+
bestCount = count;
|
|
108
|
+
}
|
|
109
|
+
return best;
|
|
110
|
+
}
|
|
111
|
+
function scopeFilter(index, scope) {
|
|
112
|
+
if (scope === undefined)
|
|
113
|
+
return () => true;
|
|
114
|
+
const states = scope.stateIds === undefined ? undefined : new Set(scope.stateIds);
|
|
115
|
+
const regions = scope.regionIds === undefined ? undefined : new Set(scope.regionIds);
|
|
116
|
+
const owners = scope.owners === undefined ? undefined : new Set(scope.owners);
|
|
117
|
+
for (const id of states ?? [])
|
|
118
|
+
if (!index.statesById.has(id))
|
|
119
|
+
refuse('MAP_TERRITORY_SCOPE_UNKNOWN', `The scope names state ${id}, which does not exist`, {
|
|
120
|
+
stateId: id,
|
|
121
|
+
});
|
|
122
|
+
return (state, region) => (states === undefined || states.has(state.id)) &&
|
|
123
|
+
(regions === undefined || regions.has(region)) &&
|
|
124
|
+
(owners === undefined || owners.has(state.owner ?? 'NONE'));
|
|
125
|
+
}
|
|
126
|
+
function buildAtomModel(world, unit, scope) {
|
|
127
|
+
const { index, table } = world;
|
|
128
|
+
const states = [...index.states].sort((a, b) => a.id - b.id);
|
|
129
|
+
const inScope = scopeFilter(index, scope);
|
|
130
|
+
const owner = new Map();
|
|
131
|
+
const groups = [];
|
|
132
|
+
const regions = [];
|
|
133
|
+
const stateOfAtom = [];
|
|
134
|
+
const atomOfState = new Map();
|
|
135
|
+
const excluded = [];
|
|
136
|
+
for (const state of states) {
|
|
137
|
+
const land = state.provinces.filter((province) => isLandProvince(table, province));
|
|
138
|
+
if (land.length === 0)
|
|
139
|
+
continue;
|
|
140
|
+
for (const province of land) {
|
|
141
|
+
const other = owner.get(province);
|
|
142
|
+
if (other !== undefined && other !== state.id)
|
|
143
|
+
refuse('MAP_TERRITORY_STATE_OVERLAP', `Province ${province} belongs to states ${other} and ${state.id}; fix the state files first (hoi4.map_inspect reports the overlap)`, { province, states: [other, state.id] });
|
|
144
|
+
owner.set(province, state.id);
|
|
145
|
+
}
|
|
146
|
+
const region = dominantRegion(index, land);
|
|
147
|
+
const wanted = inScope(state, region);
|
|
148
|
+
if (unit === 'state') {
|
|
149
|
+
atomOfState.set(state.id, groups.length);
|
|
150
|
+
groups.push(land);
|
|
151
|
+
regions.push(region);
|
|
152
|
+
stateOfAtom.push(state.id);
|
|
153
|
+
if (!wanted)
|
|
154
|
+
excluded.push(groups.length - 1);
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
for (const province of land) {
|
|
158
|
+
groups.push([province]);
|
|
159
|
+
regions.push(index.regionForProvince(province)[0]?.id ?? -1);
|
|
160
|
+
stateOfAtom.push(state.id);
|
|
161
|
+
if (!wanted)
|
|
162
|
+
excluded.push(groups.length - 1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (groups.length === 0)
|
|
166
|
+
refuse('MAP_TERRITORY_NO_LAND', 'The map has no land province in a state, so there is no territory to assign');
|
|
167
|
+
return {
|
|
168
|
+
unit,
|
|
169
|
+
atoms: buildTerritoryAtoms(world.graph, groups, Int32Array.from(regions)),
|
|
170
|
+
stateOfAtom,
|
|
171
|
+
atomOfState,
|
|
172
|
+
excluded,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function resolveProvince(world, model, province, label) {
|
|
176
|
+
const { index, table } = world;
|
|
177
|
+
if (!index.definitionsById.has(province))
|
|
178
|
+
refuse('MAP_TERRITORY_PROVINCE_UNKNOWN', `${label}: province ${province} does not exist`, {
|
|
179
|
+
province,
|
|
180
|
+
});
|
|
181
|
+
if (!isLandProvince(table, province))
|
|
182
|
+
refuse('MAP_TERRITORY_SEED_IN_WATER', `${label}: province ${province} is not land (sea and lake provinces cannot be owned)`, { province });
|
|
183
|
+
const atom = model.atoms.atomOfProvince[province];
|
|
184
|
+
if (atom < 0)
|
|
185
|
+
refuse('MAP_TERRITORY_PROVINCE_WITHOUT_STATE', `${label}: province ${province} belongs to no state, so it cannot have an owner`, { province });
|
|
186
|
+
return atom;
|
|
187
|
+
}
|
|
188
|
+
function resolveRef(world, model, ref, label) {
|
|
189
|
+
if ('state' in ref) {
|
|
190
|
+
const state = world.index.statesById.get(ref.state);
|
|
191
|
+
if (state === undefined)
|
|
192
|
+
refuse('MAP_TERRITORY_STATE_UNKNOWN', `${label}: state ${ref.state} does not exist`, {
|
|
193
|
+
stateId: ref.state,
|
|
194
|
+
});
|
|
195
|
+
if (model.unit === 'state') {
|
|
196
|
+
const atom = model.atomOfState.get(ref.state);
|
|
197
|
+
if (atom === undefined)
|
|
198
|
+
refuse('MAP_TERRITORY_SEED_IN_WATER', `${label}: state ${ref.state} has no land province`, {
|
|
199
|
+
stateId: ref.state,
|
|
200
|
+
});
|
|
201
|
+
return atom;
|
|
202
|
+
}
|
|
203
|
+
// In province mode a state seed is its capital province, else its first land province.
|
|
204
|
+
const land = state.provinces.filter((province) => isLandProvince(world.table, province));
|
|
205
|
+
const province = state.capital !== undefined && land.includes(state.capital) ? state.capital : land[0];
|
|
206
|
+
if (province === undefined)
|
|
207
|
+
refuse('MAP_TERRITORY_SEED_IN_WATER', `${label}: state ${ref.state} has no land province`, {
|
|
208
|
+
stateId: ref.state,
|
|
209
|
+
});
|
|
210
|
+
return resolveProvince(world, model, province, label);
|
|
211
|
+
}
|
|
212
|
+
if ('province' in ref)
|
|
213
|
+
return resolveProvince(world, model, ref.province, label);
|
|
214
|
+
const raster = world.index.raster;
|
|
215
|
+
if (ref.x < 0 || ref.y < 0 || ref.x >= raster.width || ref.y >= raster.height)
|
|
216
|
+
refuse('MAP_TERRITORY_POINT_OUTSIDE_MAP', `${label}: point (${ref.x}, ${ref.y}) is outside the ${raster.width} x ${raster.height} map`, { x: ref.x, y: ref.y });
|
|
217
|
+
const province = raster.provinceIds[ref.y * raster.width + ref.x];
|
|
218
|
+
return resolveProvince(world, model, province, `${label} at (${ref.x}, ${ref.y})`);
|
|
219
|
+
}
|
|
220
|
+
function resolveSelection(world, model, selection, label) {
|
|
221
|
+
const atoms = new Set();
|
|
222
|
+
if (selection === undefined)
|
|
223
|
+
return atoms;
|
|
224
|
+
for (const stateId of selection.stateIds ?? []) {
|
|
225
|
+
const state = world.index.statesById.get(stateId);
|
|
226
|
+
if (state === undefined)
|
|
227
|
+
refuse('MAP_TERRITORY_STATE_UNKNOWN', `${label}: state ${stateId} does not exist`, {
|
|
228
|
+
stateId,
|
|
229
|
+
});
|
|
230
|
+
if (model.unit === 'state') {
|
|
231
|
+
const atom = model.atomOfState.get(stateId);
|
|
232
|
+
if (atom !== undefined)
|
|
233
|
+
atoms.add(atom);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
for (const province of state.provinces) {
|
|
237
|
+
const atom = model.atoms.atomOfProvince[province];
|
|
238
|
+
if (atom >= 0)
|
|
239
|
+
atoms.add(atom);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const province of selection.provinceIds ?? []) {
|
|
243
|
+
atoms.add(resolveProvince(world, model, province, label));
|
|
244
|
+
}
|
|
245
|
+
if (selection.regionIds !== undefined) {
|
|
246
|
+
const wanted = new Set(selection.regionIds);
|
|
247
|
+
for (const region of wanted)
|
|
248
|
+
if (!world.index.regionsById.has(region))
|
|
249
|
+
refuse('MAP_TERRITORY_REGION_UNKNOWN', `${label}: strategic region ${region} does not exist`, {
|
|
250
|
+
regionId: region,
|
|
251
|
+
});
|
|
252
|
+
for (let atom = 0; atom < model.atoms.count; atom += 1)
|
|
253
|
+
if (wanted.has(model.atoms.region[atom]))
|
|
254
|
+
atoms.add(atom);
|
|
255
|
+
}
|
|
256
|
+
return atoms;
|
|
257
|
+
}
|
|
258
|
+
function ownershipPolicy(input) {
|
|
259
|
+
return {
|
|
260
|
+
controller: input?.controller ?? 'owner',
|
|
261
|
+
cores: input?.cores ?? 'keep',
|
|
262
|
+
claims: input?.claims ?? 'keep',
|
|
263
|
+
unowned: input?.unowned ?? 'clear',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const sameSet = (left, right) => {
|
|
267
|
+
const a = uniqueStrings(left);
|
|
268
|
+
const b = uniqueStrings(right);
|
|
269
|
+
return a.length === b.length && a.every((value, position) => value === b[position]);
|
|
270
|
+
};
|
|
271
|
+
/** Edit one state's owner, controller, cores and claims; returns whether anything changed. */
|
|
272
|
+
function editOwnership(changes, state, wanted, policy, operationId) {
|
|
273
|
+
if (wanted.unowned && policy.unowned === 'keep')
|
|
274
|
+
return false;
|
|
275
|
+
const desired = cloneState(state);
|
|
276
|
+
const fields = {};
|
|
277
|
+
desired.owner = wanted.owner;
|
|
278
|
+
if (desired.owner !== state.owner)
|
|
279
|
+
fields.owner = true;
|
|
280
|
+
if (policy.controller === 'owner') {
|
|
281
|
+
desired.controller = wanted.owner;
|
|
282
|
+
if (desired.controller !== state.controller)
|
|
283
|
+
fields.controller = true;
|
|
284
|
+
}
|
|
285
|
+
const tags = [wanted.owner, wanted.origin].filter((tag) => tag !== undefined);
|
|
286
|
+
if (policy.cores === 'owner')
|
|
287
|
+
desired.cores = uniqueStrings([...tags, ...wanted.addCores]);
|
|
288
|
+
else if (policy.cores === 'add-owner')
|
|
289
|
+
desired.cores = uniqueStrings([...state.cores, ...tags, ...wanted.addCores]);
|
|
290
|
+
else
|
|
291
|
+
desired.cores = uniqueStrings([...state.cores, ...wanted.addCores]);
|
|
292
|
+
if (!sameSet(desired.cores, state.cores))
|
|
293
|
+
fields.cores = true;
|
|
294
|
+
desired.claims = uniqueStrings([
|
|
295
|
+
...(policy.claims === 'clear' ? [] : state.claims),
|
|
296
|
+
...wanted.addClaims,
|
|
297
|
+
]);
|
|
298
|
+
if (!sameSet(desired.claims, state.claims))
|
|
299
|
+
fields.claims = true;
|
|
300
|
+
if (Object.keys(fields).length === 0)
|
|
301
|
+
return false;
|
|
302
|
+
commitState(changes, state, desired, fields, operationId);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
// ---------------------------------------------------------------------------------------------
|
|
306
|
+
// assign_territory
|
|
307
|
+
function provinceLabelsOf(model, owner, provinceCount) {
|
|
308
|
+
const labels = new Int32Array(provinceCount).fill(-1);
|
|
309
|
+
for (let atom = 0; atom < model.atoms.count; atom += 1)
|
|
310
|
+
for (const province of model.atoms.members[atom])
|
|
311
|
+
labels[province] = owner[atom];
|
|
312
|
+
return labels;
|
|
313
|
+
}
|
|
314
|
+
/** The state or province an atom stands for, in words and as detail fields. */
|
|
315
|
+
function describeAtom(model, atom) {
|
|
316
|
+
if (!Number.isInteger(atom) || atom < 0 || atom >= model.atoms.count)
|
|
317
|
+
return { words: `atom ${atom}`, details: {} };
|
|
318
|
+
if (model.unit === 'state') {
|
|
319
|
+
const stateId = model.stateOfAtom[atom];
|
|
320
|
+
return { words: `State ${stateId}`, details: { stateId } };
|
|
321
|
+
}
|
|
322
|
+
const provinceId = model.atoms.members[atom][0];
|
|
323
|
+
return {
|
|
324
|
+
words: `Province ${provinceId} (state ${model.stateOfAtom[atom]})`,
|
|
325
|
+
details: { provinceId, stateId: model.stateOfAtom[atom] },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Grow the territory and restate a refusal of the growth in the request's terms: the growth names
|
|
330
|
+
* internal atoms, the caller sent states, provinces and points.
|
|
331
|
+
*/
|
|
332
|
+
function growInRequestTerms(model, countries, options) {
|
|
333
|
+
try {
|
|
334
|
+
return growTerritory(model.atoms, countries, options);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (!(error instanceof ServiceError) || typeof error.details.atom !== 'number')
|
|
338
|
+
throw error;
|
|
339
|
+
const atom = describeAtom(model, error.details.atom);
|
|
340
|
+
const capital = typeof error.details.capital === 'number'
|
|
341
|
+
? describeAtom(model, error.details.capital)
|
|
342
|
+
: undefined;
|
|
343
|
+
const rest = Object.fromEntries(Object.entries(error.details).filter(([key]) => key !== 'atom' && key !== 'capital'));
|
|
344
|
+
throw new ServiceError(error.code, error.message.replace(/^Atom \d+/u, atom.words), {
|
|
345
|
+
...rest,
|
|
346
|
+
...atom.details,
|
|
347
|
+
...(capital === undefined ? {} : { capital: capital.details }),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function limited(values, limit = 1_000) {
|
|
352
|
+
return values.slice(0, limit);
|
|
353
|
+
}
|
|
354
|
+
export function* applyAssignTerritory(base, changes, allocations, operation, signal) {
|
|
355
|
+
if (operation.countries.length === 0)
|
|
356
|
+
refuse('MAP_TERRITORY_COUNTRIES_MISSING', 'assign_territory needs at least one country');
|
|
357
|
+
if (operation.countries.length > TERRITORY_MAX_COUNTRIES)
|
|
358
|
+
refuse('MAP_TERRITORY_TOO_MANY_COUNTRIES', `assign_territory accepts at most ${TERRITORY_MAX_COUNTRIES} countries, not ${operation.countries.length}`, { countries: operation.countries.length, limit: TERRITORY_MAX_COUNTRIES });
|
|
359
|
+
const unit = operation.unit ?? 'state';
|
|
360
|
+
if (unit === 'state' && operation.stateSize !== undefined)
|
|
361
|
+
refuse('MAP_TERRITORY_STATE_SIZE_NEEDS_PROVINCE_UNIT', 'stateSize regroups provinces into states; it needs unit "province"');
|
|
362
|
+
if (operation.stateSize?.max !== undefined && operation.stateSize.max < operation.stateSize.min)
|
|
363
|
+
refuse('MAP_TERRITORY_STATE_SIZE_INVALID', 'stateSize.max must not be below stateSize.min');
|
|
364
|
+
const index = currentIndex(base, changes);
|
|
365
|
+
const world = yield* buildTerritoryWorld(index, signal);
|
|
366
|
+
const model = buildAtomModel(world, unit, operation.scope);
|
|
367
|
+
const atoms = model.atoms;
|
|
368
|
+
const policy = ownershipPolicy(operation.ownership);
|
|
369
|
+
const countries = [];
|
|
370
|
+
const extraCores = [];
|
|
371
|
+
const extraClaims = [];
|
|
372
|
+
for (const country of operation.countries) {
|
|
373
|
+
const label = `Country ${country.tag}`;
|
|
374
|
+
const seeds = country.seeds.map((ref) => resolveRef(world, model, ref, `${label} seed`));
|
|
375
|
+
const forced = [...resolveSelection(world, model, country.forced, `${label} forced`)];
|
|
376
|
+
const blocked = [...resolveSelection(world, model, country.blocked, `${label} blocked`)];
|
|
377
|
+
extraCores.push(resolveSelection(world, model, country.cores, `${label} cores`));
|
|
378
|
+
extraClaims.push(resolveSelection(world, model, country.claims, `${label} claims`));
|
|
379
|
+
const entry = {
|
|
380
|
+
tag: country.tag,
|
|
381
|
+
weight: country.weight ?? 1,
|
|
382
|
+
seeds,
|
|
383
|
+
forced,
|
|
384
|
+
blocked,
|
|
385
|
+
homeRegions: country.regions ?? [],
|
|
386
|
+
};
|
|
387
|
+
if (country.overlord !== undefined)
|
|
388
|
+
entry.overlord = country.overlord;
|
|
389
|
+
if (country.mergeInto !== undefined)
|
|
390
|
+
entry.mergeInto = country.mergeInto;
|
|
391
|
+
countries.push(entry);
|
|
392
|
+
yield;
|
|
393
|
+
}
|
|
394
|
+
const unowned = [...resolveSelection(world, model, operation.unowned, 'unowned')];
|
|
395
|
+
const options = {
|
|
396
|
+
barriers: operation.barriers ?? 'natural',
|
|
397
|
+
seed: operation.seed ?? 0,
|
|
398
|
+
unowned,
|
|
399
|
+
excluded: model.excluded,
|
|
400
|
+
...(operation.shareTolerance === undefined ? {} : { shareTolerance: operation.shareTolerance }),
|
|
401
|
+
rules: {
|
|
402
|
+
...(operation.rules?.connectedMainland === undefined
|
|
403
|
+
? {}
|
|
404
|
+
: { connectedMainland: operation.rules.connectedMainland }),
|
|
405
|
+
...(operation.rules?.islandPolicy === undefined
|
|
406
|
+
? {}
|
|
407
|
+
: { islandPolicy: operation.rules.islandPolicy }),
|
|
408
|
+
},
|
|
409
|
+
smoothing: {
|
|
410
|
+
...(operation.smoothing?.passes === undefined ? {} : { passes: operation.smoothing.passes }),
|
|
411
|
+
...(operation.smoothing?.maxShareChange === undefined
|
|
412
|
+
? {}
|
|
413
|
+
: { maxShareChange: operation.smoothing.maxShareChange }),
|
|
414
|
+
},
|
|
415
|
+
...(signal === undefined ? {} : { signal }),
|
|
416
|
+
};
|
|
417
|
+
const result = growInRequestTerms(model, countries, options);
|
|
418
|
+
yield;
|
|
419
|
+
// The comparison: the same seeds and weights on distance alone.
|
|
420
|
+
const provinceCount = world.table.provinceKinds.length;
|
|
421
|
+
const baseline = growInRequestTerms(model, countries, {
|
|
422
|
+
...options,
|
|
423
|
+
barriers: 'none',
|
|
424
|
+
smoothing: { passes: 0 },
|
|
425
|
+
});
|
|
426
|
+
const score = scoreBorderAssignment(world.table, provinceLabelsOf(model, result.owner, provinceCount));
|
|
427
|
+
const baselineScore = scoreBorderAssignment(world.table, provinceLabelsOf(model, baseline.owner, provinceCount));
|
|
428
|
+
const tagOf = (country) => country < 0 ? undefined : result.tags[country];
|
|
429
|
+
const addTags = (extras, atomsOfGroup) => {
|
|
430
|
+
const tags = new Set();
|
|
431
|
+
for (const atom of atomsOfGroup)
|
|
432
|
+
for (const [position, set] of extras.entries())
|
|
433
|
+
if (set.has(atom))
|
|
434
|
+
tags.add(result.tags[position]);
|
|
435
|
+
return [...tags];
|
|
436
|
+
};
|
|
437
|
+
const states = new Map(index.statesById);
|
|
438
|
+
let ownershipChanged = 0;
|
|
439
|
+
const summary = { changed: [], created: [], removed: [] };
|
|
440
|
+
if (unit === 'state') {
|
|
441
|
+
const excluded = excludedSet(model);
|
|
442
|
+
for (let atom = 0; atom < atoms.count; atom += 1) {
|
|
443
|
+
if ((atom & 255) === 0)
|
|
444
|
+
yield;
|
|
445
|
+
if (excluded.has(atom))
|
|
446
|
+
continue;
|
|
447
|
+
const stateId = model.stateOfAtom[atom];
|
|
448
|
+
const state = states.get(stateId);
|
|
449
|
+
const country = result.owner[atom];
|
|
450
|
+
const edited = editOwnership(changes, state, {
|
|
451
|
+
owner: tagOf(country),
|
|
452
|
+
origin: country >= 0 && result.origin[atom] !== country
|
|
453
|
+
? tagOf(result.origin[atom])
|
|
454
|
+
: undefined,
|
|
455
|
+
addCores: addTags(extraCores, [atom]),
|
|
456
|
+
addClaims: addTags(extraClaims, [atom]),
|
|
457
|
+
unowned: country < 0,
|
|
458
|
+
}, policy, operation.id);
|
|
459
|
+
if (edited) {
|
|
460
|
+
ownershipChanged += 1;
|
|
461
|
+
summary.changed.push(stateId);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
const outcome = regroupProvinces(world, model, result, policy, extraCores, extraClaims, operation, changes, allocations);
|
|
467
|
+
ownershipChanged = outcome.ownershipChanged;
|
|
468
|
+
summary.changed = outcome.changed;
|
|
469
|
+
summary.created = outcome.created;
|
|
470
|
+
summary.removed = outcome.removed;
|
|
471
|
+
for (const note of outcome.notes)
|
|
472
|
+
result.notes.push(note);
|
|
473
|
+
}
|
|
474
|
+
yield;
|
|
475
|
+
const report = {
|
|
476
|
+
operationId: operation.id,
|
|
477
|
+
kind: 'assign_territory',
|
|
478
|
+
unit,
|
|
479
|
+
atoms: atoms.count,
|
|
480
|
+
seed: operation.seed ?? 0,
|
|
481
|
+
barriers: operation.barriers ?? 'natural',
|
|
482
|
+
stats: result.stats,
|
|
483
|
+
baseline: {
|
|
484
|
+
naturalShare: baselineScore.naturalShare,
|
|
485
|
+
enrichment: baselineScore.enrichment,
|
|
486
|
+
maxShareError: baseline.stats.maxShareError,
|
|
487
|
+
},
|
|
488
|
+
score,
|
|
489
|
+
states: {
|
|
490
|
+
changed: limited(summary.changed),
|
|
491
|
+
created: limited(summary.created),
|
|
492
|
+
removed: limited(summary.removed),
|
|
493
|
+
ownershipChanged,
|
|
494
|
+
},
|
|
495
|
+
notes: result.notes,
|
|
496
|
+
borderQuality: {
|
|
497
|
+
mode: operation.borderQuality ?? 'report',
|
|
498
|
+
layer: 'country',
|
|
499
|
+
...(operation.borderTargets === undefined ? {} : { targets: operation.borderTargets }),
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
return { diagnostics: territoryDiagnostics(operation, world, report, result, model), report };
|
|
503
|
+
}
|
|
504
|
+
const excludedCache = new WeakMap();
|
|
505
|
+
function excludedSet(model) {
|
|
506
|
+
let set = excludedCache.get(model);
|
|
507
|
+
if (set === undefined) {
|
|
508
|
+
set = new Set(model.excluded);
|
|
509
|
+
excludedCache.set(model, set);
|
|
510
|
+
}
|
|
511
|
+
return set;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Province unit: every state is split by owner, small fragments join a neighbour, and the states
|
|
515
|
+
* the border really cut are regrouped in one pass; the others only get their ownership edited.
|
|
516
|
+
*/
|
|
517
|
+
function regroupProvinces(world, model, result, policy, extraCores, extraClaims, operation, changes, allocations) {
|
|
518
|
+
const { index } = world;
|
|
519
|
+
const atoms = model.atoms;
|
|
520
|
+
const notes = [];
|
|
521
|
+
const excluded = excludedSet(model);
|
|
522
|
+
const pixels = (province) => index.raster?.geometry.get(province)?.pixelCount ?? 0;
|
|
523
|
+
const groups = new Map();
|
|
524
|
+
const groupOfProvince = new Map();
|
|
525
|
+
let nextId = 0;
|
|
526
|
+
const statesInOrder = [...index.states].sort((a, b) => a.id - b.id);
|
|
527
|
+
for (const state of statesInOrder) {
|
|
528
|
+
const members = state.provinces.filter((province) => atoms.atomOfProvince[province] >= 0 && !excluded.has(atoms.atomOfProvince[province]));
|
|
529
|
+
if (members.length === 0)
|
|
530
|
+
continue;
|
|
531
|
+
const byLabel = new Map();
|
|
532
|
+
for (const province of members) {
|
|
533
|
+
const label = result.owner[atoms.atomOfProvince[province]];
|
|
534
|
+
const list = byLabel.get(label) ?? [];
|
|
535
|
+
list.push(province);
|
|
536
|
+
byLabel.set(label, list);
|
|
537
|
+
}
|
|
538
|
+
const others = state.provinces.filter((province) => !members.includes(province));
|
|
539
|
+
const sortedLabels = [...byLabel.keys()].sort((a, b) => a - b);
|
|
540
|
+
// Provinces of the state that cannot be owned (sea, lake) stay with its largest part.
|
|
541
|
+
const largest = sortedLabels
|
|
542
|
+
.slice()
|
|
543
|
+
.sort((a, b) => byLabel.get(b).reduce((sum, p) => sum + pixels(p), 0) -
|
|
544
|
+
byLabel.get(a).reduce((sum, p) => sum + pixels(p), 0) || a - b)[0];
|
|
545
|
+
for (const label of sortedLabels) {
|
|
546
|
+
const provinces = [...byLabel.get(label), ...(label === largest ? others : [])].sort((a, b) => a - b);
|
|
547
|
+
const group = {
|
|
548
|
+
id: nextId++,
|
|
549
|
+
stateId: state.id,
|
|
550
|
+
label,
|
|
551
|
+
provinces,
|
|
552
|
+
region: dominantRegion(index, provinces),
|
|
553
|
+
};
|
|
554
|
+
groups.set(group.id, group);
|
|
555
|
+
for (const province of provinces)
|
|
556
|
+
groupOfProvince.set(province, group.id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Fragments below the minimum join the neighbour of the same owner and region that shares the
|
|
560
|
+
// longest border.
|
|
561
|
+
const minimum = operation.stateSize?.min ?? 1;
|
|
562
|
+
const maximum = operation.stateSize?.max ?? Number.POSITIVE_INFINITY;
|
|
563
|
+
if (minimum > 1) {
|
|
564
|
+
const graph = world.graph.graph;
|
|
565
|
+
const neighbours = new Map();
|
|
566
|
+
const link = (a, b, length) => {
|
|
567
|
+
const row = neighbours.get(a) ?? new Map();
|
|
568
|
+
row.set(b, (row.get(b) ?? 0) + length);
|
|
569
|
+
neighbours.set(a, row);
|
|
570
|
+
};
|
|
571
|
+
for (const group of groups.values())
|
|
572
|
+
for (const province of group.provinces)
|
|
573
|
+
for (let slot = graph.offsets[province]; slot < graph.offsets[province + 1]; slot += 1) {
|
|
574
|
+
const other = groupOfProvince.get(graph.to[slot]);
|
|
575
|
+
if (other !== undefined && other !== group.id)
|
|
576
|
+
link(group.id, other, graph.length[slot]);
|
|
577
|
+
}
|
|
578
|
+
const stuck = new Set();
|
|
579
|
+
for (;;) {
|
|
580
|
+
const small = [...groups.values()]
|
|
581
|
+
.filter((group) => group.provinces.length < minimum && !stuck.has(group.id))
|
|
582
|
+
.sort((a, b) => a.provinces.length - b.provinces.length || a.id - b.id)[0];
|
|
583
|
+
if (small === undefined)
|
|
584
|
+
break;
|
|
585
|
+
let target;
|
|
586
|
+
let best = -1;
|
|
587
|
+
for (const [otherId, length] of [...(neighbours.get(small.id) ?? [])].sort(([a], [b]) => a - b)) {
|
|
588
|
+
const other = groups.get(otherId);
|
|
589
|
+
if (other?.label !== small.label ||
|
|
590
|
+
other.region !== small.region ||
|
|
591
|
+
other.provinces.length + small.provinces.length > maximum)
|
|
592
|
+
continue;
|
|
593
|
+
if (length > best) {
|
|
594
|
+
best = length;
|
|
595
|
+
target = other;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (target === undefined) {
|
|
599
|
+
stuck.add(small.id);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
target.provinces = [...target.provinces, ...small.provinces].sort((a, b) => a - b);
|
|
603
|
+
for (const province of small.provinces)
|
|
604
|
+
groupOfProvince.set(province, target.id);
|
|
605
|
+
for (const [otherId, length] of neighbours.get(small.id) ?? []) {
|
|
606
|
+
if (otherId === target.id)
|
|
607
|
+
continue;
|
|
608
|
+
link(target.id, otherId, length);
|
|
609
|
+
const row = neighbours.get(otherId);
|
|
610
|
+
if (row !== undefined) {
|
|
611
|
+
row.delete(small.id);
|
|
612
|
+
row.set(target.id, (row.get(target.id) ?? 0) + length);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
neighbours.get(target.id)?.delete(small.id);
|
|
616
|
+
neighbours.delete(small.id);
|
|
617
|
+
groups.delete(small.id);
|
|
618
|
+
}
|
|
619
|
+
if (stuck.size > 0)
|
|
620
|
+
notes.push({
|
|
621
|
+
code: 'MAP_TERRITORY_STATE_SIZE_UNMET',
|
|
622
|
+
message: `${stuck.size} state fragment(s) below ${minimum} provinces have no neighbour of the same owner and strategic region to join and stay as they are`,
|
|
623
|
+
details: { fragments: stuck.size },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
// Groups equal to an old state only change owner; the others are regrouped in one pass.
|
|
627
|
+
const restructure = [];
|
|
628
|
+
let ownershipChanged = 0;
|
|
629
|
+
const changed = [];
|
|
630
|
+
const tagOf = (country) => country < 0 ? undefined : result.tags[country];
|
|
631
|
+
const originTag = (group) => {
|
|
632
|
+
const atom = atoms.atomOfProvince[group.provinces.find((p) => atoms.atomOfProvince[p] >= 0)];
|
|
633
|
+
const origin = result.origin[atom];
|
|
634
|
+
return origin !== undefined && origin >= 0 && origin !== group.label
|
|
635
|
+
? tagOf(origin)
|
|
636
|
+
: undefined;
|
|
637
|
+
};
|
|
638
|
+
const tagsFor = (extras, group) => {
|
|
639
|
+
const tags = new Set();
|
|
640
|
+
for (const province of group.provinces) {
|
|
641
|
+
const atom = atoms.atomOfProvince[province];
|
|
642
|
+
if (atom < 0)
|
|
643
|
+
continue;
|
|
644
|
+
for (const [position, set] of extras.entries())
|
|
645
|
+
if (set.has(atom))
|
|
646
|
+
tags.add(result.tags[position]);
|
|
647
|
+
}
|
|
648
|
+
return [...tags];
|
|
649
|
+
};
|
|
650
|
+
for (const group of [...groups.values()].sort((a, b) => a.id - b.id)) {
|
|
651
|
+
const old = index.statesById.get(group.stateId);
|
|
652
|
+
const oldSorted = [...old.provinces].sort((a, b) => a - b);
|
|
653
|
+
const same = group.provinces.length === oldSorted.length &&
|
|
654
|
+
group.provinces.every((province, position) => province === oldSorted[position]);
|
|
655
|
+
const owner = tagOf(group.label);
|
|
656
|
+
const origin = originTag(group);
|
|
657
|
+
const addCores = tagsFor(extraCores, group);
|
|
658
|
+
const addClaims = tagsFor(extraClaims, group);
|
|
659
|
+
if (same) {
|
|
660
|
+
if (editOwnership(changes, old, { owner, origin, addCores, addClaims, unowned: group.label < 0 }, policy, operation.id)) {
|
|
661
|
+
ownershipChanged += 1;
|
|
662
|
+
changed.push(old.id);
|
|
663
|
+
}
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const tags = [owner, origin].filter((tag) => tag !== undefined);
|
|
667
|
+
const entry = { provinces: group.provinces };
|
|
668
|
+
if (!(group.label < 0 && policy.unowned === 'keep'))
|
|
669
|
+
entry.owner = owner ?? null;
|
|
670
|
+
if (policy.controller === 'owner' && !(group.label < 0 && policy.unowned === 'keep'))
|
|
671
|
+
entry.controller = owner ?? null;
|
|
672
|
+
if (policy.cores === 'owner')
|
|
673
|
+
entry.cores = uniqueStrings([...tags, ...addCores]);
|
|
674
|
+
else
|
|
675
|
+
entry.addCores = uniqueStrings([...(policy.cores === 'add-owner' ? tags : []), ...addCores]);
|
|
676
|
+
if (policy.claims === 'clear')
|
|
677
|
+
entry.claims = [];
|
|
678
|
+
if (addClaims.length > 0)
|
|
679
|
+
entry.addClaims = addClaims;
|
|
680
|
+
restructure.push(entry);
|
|
681
|
+
}
|
|
682
|
+
let created = [];
|
|
683
|
+
let removed = [];
|
|
684
|
+
if (restructure.length > 0) {
|
|
685
|
+
const summary = restructureStates(world.index, changes, allocations, restructure, {
|
|
686
|
+
operationId: operation.id,
|
|
687
|
+
...(operation.remapReferences === undefined
|
|
688
|
+
? {}
|
|
689
|
+
: { remapReferences: operation.remapReferences }),
|
|
690
|
+
});
|
|
691
|
+
created = summary.created;
|
|
692
|
+
removed = summary.removed;
|
|
693
|
+
changed.push(...summary.changed);
|
|
694
|
+
ownershipChanged += restructure.length;
|
|
695
|
+
}
|
|
696
|
+
return { ownershipChanged, changed, created, removed, notes };
|
|
697
|
+
}
|
|
698
|
+
// ---------------------------------------------------------------------------------------------
|
|
699
|
+
// Diagnostics
|
|
700
|
+
function territoryDiagnostics(operation, world, report, result, model) {
|
|
701
|
+
const diagnostics = [];
|
|
702
|
+
const stats = result.stats;
|
|
703
|
+
const worst = stats.countries
|
|
704
|
+
.slice()
|
|
705
|
+
.sort((a, b) => b.shareError - a.shareError || (a.tag < b.tag ? -1 : 1))
|
|
706
|
+
.slice(0, 5)
|
|
707
|
+
.map(({ tag, share, targetShare, shareError }) => ({ tag, share, targetShare, shareError }));
|
|
708
|
+
diagnostics.push({
|
|
709
|
+
code: 'MAP_TERRITORY_ASSIGNED',
|
|
710
|
+
severity: 'info',
|
|
711
|
+
category: 'map',
|
|
712
|
+
operationId: operation.id,
|
|
713
|
+
message: `Assigned ${stats.assignedAtoms} ${model.unit}${stats.assignedAtoms === 1 ? '' : 's'} to ${stats.countries.length} countries: ${report.states.ownershipChanged} state(s) changed owner or ownership, ${report.states.created.length} created, ${report.states.removed.length} removed. Natural share of country borders ${report.score.naturalShare ?? 'n/a'} (distance only ${report.baseline?.naturalShare ?? 'n/a'}), enrichment ${report.score.enrichment ?? 'n/a'} (distance only ${report.baseline?.enrichment ?? 'n/a'}); largest size error ${(stats.maxShareError * 100).toFixed(1)}%.`,
|
|
714
|
+
details: {
|
|
715
|
+
unit: model.unit,
|
|
716
|
+
countries: stats.countries.length,
|
|
717
|
+
assignedAtoms: stats.assignedAtoms,
|
|
718
|
+
unownedAtoms: stats.unownedAtoms,
|
|
719
|
+
maxShareError: stats.maxShareError,
|
|
720
|
+
rounds: stats.rounds,
|
|
721
|
+
smoothingMoves: stats.smoothingMoves,
|
|
722
|
+
corridors: stats.corridors,
|
|
723
|
+
score: report.score,
|
|
724
|
+
baseline: report.baseline,
|
|
725
|
+
worstShareErrors: worst,
|
|
726
|
+
states: {
|
|
727
|
+
changed: report.states.changed.length,
|
|
728
|
+
created: report.states.created.length,
|
|
729
|
+
removed: report.states.removed.length,
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
const tolerance = operation.shareTolerance ?? 0.15;
|
|
734
|
+
if (stats.maxShareError > tolerance)
|
|
735
|
+
diagnostics.push({
|
|
736
|
+
code: 'MAP_TERRITORY_SHARE_OFF',
|
|
737
|
+
severity: 'warning',
|
|
738
|
+
category: 'map',
|
|
739
|
+
operationId: operation.id,
|
|
740
|
+
message: `${stats.countries.filter((entry) => entry.shareError > tolerance).length} countr${stats.countries.filter((entry) => entry.shareError > tolerance).length === 1 ? 'y is' : 'ies are'} more than ${(tolerance * 100).toFixed(0)}% from their target size (largest ${(stats.maxShareError * 100).toFixed(1)}%); whole ${model.unit}s are too coarse, blocked or forced land limits them, or islands went to a neighbour. Use unit "province" or move seeds.`,
|
|
741
|
+
details: { tolerance, worst },
|
|
742
|
+
});
|
|
743
|
+
const split = stats.countries.filter((entry) => entry.pieces > entry.landmasses);
|
|
744
|
+
if (split.length > 0)
|
|
745
|
+
diagnostics.push({
|
|
746
|
+
code: 'MAP_TERRITORY_DISCONNECTED',
|
|
747
|
+
severity: 'warning',
|
|
748
|
+
category: 'map',
|
|
749
|
+
operationId: operation.id,
|
|
750
|
+
message: `${split.length} countr${split.length === 1 ? 'y has' : 'ies have'} territory in several pieces on one landmass (${split
|
|
751
|
+
.slice(0, 8)
|
|
752
|
+
.map(({ tag }) => tag)
|
|
753
|
+
.join(', ')}); anchors or forced land lie apart from the capital`,
|
|
754
|
+
details: {
|
|
755
|
+
countries: split
|
|
756
|
+
.slice(0, 20)
|
|
757
|
+
.map(({ tag, pieces, landmasses }) => ({ tag, pieces, landmasses })),
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
const missing = ['heights', 'rivers', 'terrain'].filter((name) => !world.table.inputs[name]);
|
|
761
|
+
if (missing.length === 3)
|
|
762
|
+
diagnostics.push({
|
|
763
|
+
code: 'MAP_TERRITORY_NO_FEATURES',
|
|
764
|
+
severity: 'info',
|
|
765
|
+
category: 'map',
|
|
766
|
+
operationId: operation.id,
|
|
767
|
+
message: 'The mod has no height, river or terrain bitmap of the province map size, so there are no natural lines to follow; borders fall back to distance and the requested sizes',
|
|
768
|
+
details: { skipped: world.skipped },
|
|
769
|
+
});
|
|
770
|
+
for (const note of result.notes)
|
|
771
|
+
diagnostics.push({
|
|
772
|
+
code: note.code,
|
|
773
|
+
severity: 'info',
|
|
774
|
+
category: 'map',
|
|
775
|
+
operationId: operation.id,
|
|
776
|
+
message: note.message,
|
|
777
|
+
...(note.details === undefined ? {} : { details: note.details }),
|
|
778
|
+
});
|
|
779
|
+
return diagnostics;
|
|
780
|
+
}
|
|
781
|
+
// ---------------------------------------------------------------------------------------------
|
|
782
|
+
// repartition_states
|
|
783
|
+
export function* applyRepartitionStates(base, changes, allocations, operation, signal) {
|
|
784
|
+
const min = operation.provincesPerState?.min ?? 3;
|
|
785
|
+
const max = operation.provincesPerState?.max ?? 10;
|
|
786
|
+
const target = operation.provincesPerState?.target ?? Math.round((min + max) / 2);
|
|
787
|
+
if (!(min >= 1 && min <= target && target <= max))
|
|
788
|
+
refuse('MAP_TERRITORY_STATE_SIZE_INVALID', `provincesPerState needs 1 <= min <= target <= max; got ${min}, ${target}, ${max}`, { min, target, max });
|
|
789
|
+
const index = currentIndex(base, changes);
|
|
790
|
+
const world = yield* buildTerritoryWorld(index, signal);
|
|
791
|
+
const owners = operation.countries === undefined ? undefined : new Set(operation.countries);
|
|
792
|
+
const wantedStates = operation.stateIds === undefined ? undefined : new Set(operation.stateIds);
|
|
793
|
+
for (const id of wantedStates ?? [])
|
|
794
|
+
if (!index.statesById.has(id))
|
|
795
|
+
refuse('MAP_TERRITORY_STATE_UNKNOWN', `State ${id} does not exist`, { stateId: id });
|
|
796
|
+
const notes = [];
|
|
797
|
+
// Buckets: states of one owner whose land lies in one strategic region.
|
|
798
|
+
const buckets = new Map();
|
|
799
|
+
let skipped = 0;
|
|
800
|
+
const statesInOrder = [...index.states].sort((a, b) => a.id - b.id);
|
|
801
|
+
for (const state of statesInOrder) {
|
|
802
|
+
if (wantedStates !== undefined && !wantedStates.has(state.id))
|
|
803
|
+
continue;
|
|
804
|
+
if (owners !== undefined && !owners.has(state.owner ?? 'NONE'))
|
|
805
|
+
continue;
|
|
806
|
+
const land = state.provinces.filter((province) => isLandProvince(world.table, province));
|
|
807
|
+
if (land.length === 0)
|
|
808
|
+
continue;
|
|
809
|
+
const regions = new Set(state.provinces.map((province) => index.regionForProvince(province)[0]?.id ?? -1));
|
|
810
|
+
if (regions.size > 1 ||
|
|
811
|
+
state.provinces.some((province) => index.stateForProvince(province).length !== 1)) {
|
|
812
|
+
skipped += 1;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
const region = [...regions][0];
|
|
816
|
+
const key = `${state.owner ?? ''}:${region}`;
|
|
817
|
+
const bucket = buckets.get(key) ?? { owner: state.owner ?? '', region, states: [] };
|
|
818
|
+
bucket.states.push(state);
|
|
819
|
+
buckets.set(key, bucket);
|
|
820
|
+
}
|
|
821
|
+
if (skipped > 0)
|
|
822
|
+
notes.push({
|
|
823
|
+
code: 'MAP_TERRITORY_STATE_SKIPPED',
|
|
824
|
+
message: `${skipped} state(s) span several strategic regions or share provinces with another state and were left as they are`,
|
|
825
|
+
details: { skipped },
|
|
826
|
+
});
|
|
827
|
+
if (buckets.size === 0)
|
|
828
|
+
refuse('MAP_TERRITORY_NOTHING_TO_REPARTITION', 'No state matches the selection (owners, stateIds), so there is nothing to regroup');
|
|
829
|
+
const graph = world.graph.graph;
|
|
830
|
+
const oldLabel = new Int32Array(world.table.provinceKinds.length).fill(-1);
|
|
831
|
+
const newLabel = new Int32Array(world.table.provinceKinds.length).fill(-1);
|
|
832
|
+
const restructure = [];
|
|
833
|
+
let groupCount = 0;
|
|
834
|
+
let unchanged = 0;
|
|
835
|
+
for (const bucket of [...buckets.values()].sort((a, b) => a.owner === b.owner ? a.region - b.region : a.owner < b.owner ? -1 : 1)) {
|
|
836
|
+
yield;
|
|
837
|
+
const members = bucket.states.flatMap((state) => state.provinces.filter((province) => isLandProvince(world.table, province)));
|
|
838
|
+
for (const state of bucket.states)
|
|
839
|
+
for (const province of state.provinces)
|
|
840
|
+
oldLabel[province] = state.id;
|
|
841
|
+
const clusters = clusterProvinces(members, graph, { min, target, max });
|
|
842
|
+
const byCluster = new Map();
|
|
843
|
+
for (const [province, cluster] of clusters) {
|
|
844
|
+
const list = byCluster.get(cluster) ?? [];
|
|
845
|
+
list.push(province);
|
|
846
|
+
byCluster.set(cluster, list);
|
|
847
|
+
}
|
|
848
|
+
// Provinces that are not land (inside a state) stay with the cluster holding most of their state.
|
|
849
|
+
const clusterOfState = new Map();
|
|
850
|
+
for (const state of bucket.states) {
|
|
851
|
+
const tally = new Map();
|
|
852
|
+
for (const province of state.provinces) {
|
|
853
|
+
const cluster = clusters.get(province);
|
|
854
|
+
if (cluster !== undefined)
|
|
855
|
+
tally.set(cluster, (tally.get(cluster) ?? 0) + 1);
|
|
856
|
+
}
|
|
857
|
+
const best = [...tally].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0];
|
|
858
|
+
if (best !== undefined)
|
|
859
|
+
clusterOfState.set(state.id, best[0]);
|
|
860
|
+
for (const province of state.provinces)
|
|
861
|
+
if (!clusters.has(province) && best !== undefined)
|
|
862
|
+
byCluster.get(best[0]).push(province);
|
|
863
|
+
}
|
|
864
|
+
for (const cluster of [...byCluster.keys()].sort((a, b) => a - b)) {
|
|
865
|
+
const provinces = byCluster.get(cluster).sort((a, b) => a - b);
|
|
866
|
+
const id = groupCount++;
|
|
867
|
+
for (const province of provinces)
|
|
868
|
+
newLabel[province] = id;
|
|
869
|
+
const first = bucket.states.find((state) => state.provinces.includes(provinces[0]));
|
|
870
|
+
const identical = first?.provinces.length === provinces.length &&
|
|
871
|
+
[...first.provinces]
|
|
872
|
+
.sort((a, b) => a - b)
|
|
873
|
+
.every((province, position) => province === provinces[position]);
|
|
874
|
+
if (identical) {
|
|
875
|
+
unchanged += 1;
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
restructure.push({ provinces });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
yield;
|
|
882
|
+
let summary = { changed: [], created: [], removed: [] };
|
|
883
|
+
if (restructure.length > 0)
|
|
884
|
+
summary = restructureStates(index, changes, allocations, restructure, {
|
|
885
|
+
operationId: operation.id,
|
|
886
|
+
...(operation.remapReferences === undefined
|
|
887
|
+
? {}
|
|
888
|
+
: { remapReferences: operation.remapReferences }),
|
|
889
|
+
});
|
|
890
|
+
yield;
|
|
891
|
+
const before = scoreBorderAssignment(world.table, oldLabel);
|
|
892
|
+
const after = scoreBorderAssignment(world.table, newLabel);
|
|
893
|
+
const report = {
|
|
894
|
+
operationId: operation.id,
|
|
895
|
+
kind: 'repartition_states',
|
|
896
|
+
unit: 'province',
|
|
897
|
+
atoms: groupCount,
|
|
898
|
+
seed: 0,
|
|
899
|
+
barriers: 'natural',
|
|
900
|
+
score: after,
|
|
901
|
+
before,
|
|
902
|
+
states: {
|
|
903
|
+
changed: limited(summary.changed),
|
|
904
|
+
created: limited(summary.created),
|
|
905
|
+
removed: limited(summary.removed),
|
|
906
|
+
ownershipChanged: 0,
|
|
907
|
+
},
|
|
908
|
+
notes,
|
|
909
|
+
borderQuality: {
|
|
910
|
+
mode: operation.borderQuality ?? 'report',
|
|
911
|
+
layer: 'state',
|
|
912
|
+
...(operation.borderTargets === undefined ? {} : { targets: operation.borderTargets }),
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
const diagnostics = [
|
|
916
|
+
{
|
|
917
|
+
code: 'MAP_TERRITORY_REPARTITIONED',
|
|
918
|
+
severity: 'info',
|
|
919
|
+
category: 'map',
|
|
920
|
+
operationId: operation.id,
|
|
921
|
+
message: `Regrouped ${buckets.size} owner and region bucket(s) into ${groupCount} state(s): ${unchanged} kept as they were, ${summary.changed.length} rewritten, ${summary.created.length} created, ${summary.removed.length} removed. Natural share of state borders ${after.naturalShare ?? 'n/a'} (before ${before.naturalShare ?? 'n/a'}), enrichment ${after.enrichment ?? 'n/a'} (before ${before.enrichment ?? 'n/a'}).`,
|
|
922
|
+
details: {
|
|
923
|
+
buckets: buckets.size,
|
|
924
|
+
states: groupCount,
|
|
925
|
+
unchanged,
|
|
926
|
+
rewritten: summary.changed.length,
|
|
927
|
+
created: summary.created.length,
|
|
928
|
+
removed: summary.removed.length,
|
|
929
|
+
score: after,
|
|
930
|
+
before,
|
|
931
|
+
provincesPerState: { min, target, max },
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
...notes.map((note) => ({
|
|
935
|
+
code: note.code,
|
|
936
|
+
severity: 'info',
|
|
937
|
+
category: 'map',
|
|
938
|
+
operationId: operation.id,
|
|
939
|
+
message: note.message,
|
|
940
|
+
...(note.details === undefined ? {} : { details: note.details }),
|
|
941
|
+
})),
|
|
942
|
+
];
|
|
943
|
+
return { diagnostics, report };
|
|
944
|
+
}
|