hoi4-agent-tools 4.5.0 → 4.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
- package/dist/hoi4_agent_tools/map/derived.js +16 -3
- package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
- package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
- package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
- package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
- package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
- package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
- package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
- package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
- package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
- package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
- package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
- package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
- package/dist/hoi4_agent_tools/map/operations.js +109 -32
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +107 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +52 -0
- package/dist/hoi4_agent_tools/map/service.js +78 -16
- package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
- package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
- package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
- package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
- package/dist/hoi4_agent_tools/map/territory.js +1108 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +78 -0
- package/dist/hoi4_agent_tools/map/validation.js +4 -0
- package/dist/hoi4_agent_tools/map/world.js +301 -107
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +3 -0
- package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
- package/dist/hoi4_agent_tools/schemas/map.js +206 -21
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/map.md +100 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/geography-model.schema.json +13 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1355 -736
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/reference-sheets.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { compareCodeUnits } from '../core/canonical.js';
|
|
2
|
+
import { ServiceError } from '../core/result.js';
|
|
3
|
+
import { assignments, encodeSource, parseLocalisation } from '../core/source/index.js';
|
|
4
|
+
import { parseMapDate } from './model.js';
|
|
5
|
+
import { addChange, appendLocalisationEntry, applyTextRanges, assertDatedStateHistorySafe, buildingPositionLine, cloneState, commitState, compileState, currentIndex, escapedLocalisationValue, isLevelStateBuilding, localisationEntryLine, maximumObservedId, normalizeRelative, regionMembershipId, removalContent, remapScriptReferences, requireSafeLocalisationDocument, rootEvidence, uniqueStrings, } from './operations.js';
|
|
6
|
+
/**
|
|
7
|
+
* Split `total` into whole shares proportional to `weights` so the parts add up exactly. Integers
|
|
8
|
+
* use the largest-remainder rule; a value with decimals is shared in hundredths.
|
|
9
|
+
*/
|
|
10
|
+
export function apportion(total, weights) {
|
|
11
|
+
const scale = Number.isInteger(total) ? 1 : 100;
|
|
12
|
+
const units = Math.round(total * scale);
|
|
13
|
+
const sum = weights.reduce((acc, weight) => acc + weight, 0);
|
|
14
|
+
if (weights.length === 0)
|
|
15
|
+
return [];
|
|
16
|
+
if (sum <= 0) {
|
|
17
|
+
const shares = weights.map(() => 0);
|
|
18
|
+
shares[0] = units;
|
|
19
|
+
return shares.map((share) => share / scale);
|
|
20
|
+
}
|
|
21
|
+
const raw = weights.map((weight) => (units * weight) / sum);
|
|
22
|
+
const shares = raw.map((value) => Math.floor(value));
|
|
23
|
+
let remaining = units - shares.reduce((acc, share) => acc + share, 0);
|
|
24
|
+
const order = raw
|
|
25
|
+
.map((value, index) => ({ index, remainder: value - Math.floor(value) }))
|
|
26
|
+
.sort((left, right) => right.remainder - left.remainder || left.index - right.index);
|
|
27
|
+
for (let position = 0; remaining > 0 && order.length > 0; position += 1, remaining -= 1)
|
|
28
|
+
shares[order[position % order.length].index] += 1;
|
|
29
|
+
return shares.map((share) => share / scale);
|
|
30
|
+
}
|
|
31
|
+
function statesOfGroups(index, groups) {
|
|
32
|
+
const owner = new Map();
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
for (const [position, group] of groups.entries()) {
|
|
35
|
+
if (group.provinces.length === 0)
|
|
36
|
+
throw new ServiceError('MAP_STATE_WOULD_BE_EMPTY', `State group ${position} has no provinces`);
|
|
37
|
+
for (const province of group.provinces) {
|
|
38
|
+
if (seen.has(province))
|
|
39
|
+
throw new ServiceError('MAP_STATE_PROVINCE_SELECTION_INVALID', `Province ${province} appears in state groups ${seen.get(province)} and ${position}`, { province });
|
|
40
|
+
seen.set(province, position);
|
|
41
|
+
const states = index.stateForProvince(province);
|
|
42
|
+
if (states.length !== 1)
|
|
43
|
+
throw new ServiceError('MAP_STATE_MEMBERSHIP_AMBIGUOUS', `Province ${province} must belong to exactly one state to be regrouped`, { province, states: states.map(({ id }) => id) });
|
|
44
|
+
owner.set(province, states[0]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { stateOfProvince: owner, groupOfProvince: seen };
|
|
48
|
+
}
|
|
49
|
+
/** Rewrite the state files so that exactly `groups` hold the provinces of the states they touch. */
|
|
50
|
+
export function restructureStates(base, changes, allocations, groups, options) {
|
|
51
|
+
const index = currentIndex(base, changes);
|
|
52
|
+
const operationId = options.operationId;
|
|
53
|
+
const { stateOfProvince, groupOfProvince } = statesOfGroups(index, groups);
|
|
54
|
+
const affected = new Map();
|
|
55
|
+
for (const state of stateOfProvince.values())
|
|
56
|
+
affected.set(state.id, state);
|
|
57
|
+
if (affected.size === 0)
|
|
58
|
+
return { changed: [], created: [], removed: [], heirs: [], stateIds: [] };
|
|
59
|
+
// The groups must cover every province of every state they touch.
|
|
60
|
+
for (const state of affected.values())
|
|
61
|
+
for (const province of state.provinces)
|
|
62
|
+
if (!groupOfProvince.has(province))
|
|
63
|
+
throw new ServiceError('MAP_STATE_RESTRUCTURE_INCOMPLETE', `State ${state.id} would lose province ${province} to no state; every province of a regrouped state needs a group`, { stateId: state.id, province });
|
|
64
|
+
const pixels = (province) => index.raster?.geometry.get(province)?.pixelCount ?? 0;
|
|
65
|
+
// Pixel overlap of every group with every old state.
|
|
66
|
+
const overlap = groups.map(() => new Map());
|
|
67
|
+
const stateTotals = new Map();
|
|
68
|
+
for (const [position, group] of groups.entries())
|
|
69
|
+
for (const province of group.provinces) {
|
|
70
|
+
const state = stateOfProvince.get(province);
|
|
71
|
+
const size = pixels(province);
|
|
72
|
+
overlap[position].set(state.id, (overlap[position].get(state.id) ?? 0) + size);
|
|
73
|
+
}
|
|
74
|
+
for (const state of affected.values())
|
|
75
|
+
stateTotals.set(state.id, state.provinces.reduce((sum, province) => sum + pixels(province), 0));
|
|
76
|
+
for (const [stateId, total] of stateTotals)
|
|
77
|
+
if (total <= 0)
|
|
78
|
+
throw new ServiceError('MAP_STATE_DISTRIBUTION_GEOMETRY_MISSING', `State ${stateId} has no province geometry; proportional distribution needs the province map`, { stateId });
|
|
79
|
+
// Heirs: the biggest overlaps first; ties go to the lower state id and group index.
|
|
80
|
+
const pairs = [];
|
|
81
|
+
for (const [position, map] of overlap.entries())
|
|
82
|
+
for (const [state, px] of map)
|
|
83
|
+
pairs.push({ group: position, state, pixels: px });
|
|
84
|
+
pairs.sort((a, b) => b.pixels - a.pixels || a.state - b.state || a.group - b.group);
|
|
85
|
+
const heirOfGroup = new Map();
|
|
86
|
+
const groupOfHeir = new Map();
|
|
87
|
+
for (const pair of pairs) {
|
|
88
|
+
if (heirOfGroup.has(pair.group) || groupOfHeir.has(pair.state))
|
|
89
|
+
continue;
|
|
90
|
+
heirOfGroup.set(pair.group, pair.state);
|
|
91
|
+
groupOfHeir.set(pair.state, pair.group);
|
|
92
|
+
}
|
|
93
|
+
// A group takes provinces of one strategic region only.
|
|
94
|
+
for (const [position, group] of groups.entries()) {
|
|
95
|
+
const regions = new Set(group.provinces.map((province) => regionMembershipId(index, province)));
|
|
96
|
+
if (regions.size > 1)
|
|
97
|
+
throw new ServiceError('MAP_STATE_REGION_DISTRIBUTION_CONFLICT', `State group ${position} (provinces ${group.provinces.slice(0, 6).join(', ')}...) spans strategic regions ${[...regions].join(', ')}; a state may hold provinces of one region only`, { group: position, regions: [...regions] });
|
|
98
|
+
}
|
|
99
|
+
// Dated history cannot follow a regrouping safely.
|
|
100
|
+
const removedStates = [...affected.values()].filter((state) => !groupOfHeir.has(state.id));
|
|
101
|
+
for (const state of removedStates) {
|
|
102
|
+
const dated = state.historyBlock === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: assignments(state.historyBlock).find((entry) => entry.value.type === 'block' && parseMapDate(entry.key.value) !== undefined);
|
|
105
|
+
if (dated !== undefined)
|
|
106
|
+
throw new ServiceError('MAP_DATED_STATE_HISTORY_CONFLICT', `State ${state.id} has dated history (${dated.key.value}) that would be lost when its provinces move to other states; edit or move that block first`, { stateId: state.id, datedAt: dated.key.value, sourcePath: state.file.displayPath });
|
|
107
|
+
}
|
|
108
|
+
// Allocate identifiers for groups with no heir: the lowest positive ids no root uses.
|
|
109
|
+
const used = new Set(index.statesAcrossRoots.map(({ id }) => id));
|
|
110
|
+
const highest = maximumObservedId(index.statesAcrossRoots.map(({ id }) => id), 0);
|
|
111
|
+
const roots = rootEvidence(index.statesAcrossRoots.map(({ id, file }) => ({ id, file })));
|
|
112
|
+
const stateIds = [];
|
|
113
|
+
let cursor = 1;
|
|
114
|
+
let probes = 0;
|
|
115
|
+
for (const position of groups.keys()) {
|
|
116
|
+
const heir = heirOfGroup.get(position);
|
|
117
|
+
if (heir !== undefined) {
|
|
118
|
+
stateIds.push(heir);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
while (used.has(cursor)) {
|
|
122
|
+
cursor += 1;
|
|
123
|
+
probes += 1;
|
|
124
|
+
}
|
|
125
|
+
if (cursor >= 2_147_483_647)
|
|
126
|
+
throw new ServiceError('MAP_STATE_ID_EXHAUSTED', 'No safe state ID remains available');
|
|
127
|
+
used.add(cursor);
|
|
128
|
+
stateIds.push(cursor);
|
|
129
|
+
allocations.push({
|
|
130
|
+
kind: 'state-id',
|
|
131
|
+
allocated: cursor,
|
|
132
|
+
strategy: 'lowest-positive-unused-active-id-across-roots',
|
|
133
|
+
highestObserved: highest,
|
|
134
|
+
probes: probes + 1,
|
|
135
|
+
roots,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const contributions = groups.map(() => new Map());
|
|
139
|
+
for (const state of affected.values()) {
|
|
140
|
+
const takers = groups.flatMap((_, position) => overlap[position].has(state.id) ? [position] : []);
|
|
141
|
+
const weights = takers.map((position) => overlap[position].get(state.id));
|
|
142
|
+
const manpower = apportion(state.manpower, weights);
|
|
143
|
+
const resourceShares = new Map();
|
|
144
|
+
for (const [resource, amount] of state.resources)
|
|
145
|
+
resourceShares.set(resource, apportion(amount, weights));
|
|
146
|
+
const buildingShares = new Map();
|
|
147
|
+
for (const [building, amount] of state.stateBuildings)
|
|
148
|
+
buildingShares.set(building, isLevelStateBuilding(index, building) ? 'level' : apportion(amount, weights));
|
|
149
|
+
for (const [slot, position] of takers.entries()) {
|
|
150
|
+
const resources = new Map();
|
|
151
|
+
for (const [resource, shares] of resourceShares)
|
|
152
|
+
if (shares[slot] !== 0)
|
|
153
|
+
resources.set(resource, shares[slot]);
|
|
154
|
+
const buildings = new Map();
|
|
155
|
+
for (const [building, shares] of buildingShares) {
|
|
156
|
+
const value = shares === 'level' ? state.stateBuildings.get(building) : shares[slot];
|
|
157
|
+
if (value !== 0)
|
|
158
|
+
buildings.set(building, value);
|
|
159
|
+
}
|
|
160
|
+
contributions[position].set(state.id, { manpower: manpower[slot], resources, buildings });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Ownership fields written for a group, and whether they differ from the heir's.
|
|
164
|
+
const sameList = (left, right) => left.length === right.length && left.every((value, position) => value === right[position]);
|
|
165
|
+
const changed = [];
|
|
166
|
+
const created = [];
|
|
167
|
+
const names = [];
|
|
168
|
+
const nameCounts = new Map();
|
|
169
|
+
const provinceState = new Map();
|
|
170
|
+
const statesRoot = index.sourceRoots.states[0] ?? 'history/states';
|
|
171
|
+
const reservedFiles = new Set();
|
|
172
|
+
for (const [position, group] of groups.entries()) {
|
|
173
|
+
const heirId = heirOfGroup.get(position);
|
|
174
|
+
const parts = [...contributions[position].keys()].sort((a, b) => a - b);
|
|
175
|
+
const sources = parts.map((id) => affected.get(id));
|
|
176
|
+
const primaryId = heirId ?? [...overlap[position]].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0][0];
|
|
177
|
+
const primary = affected.get(primaryId);
|
|
178
|
+
const provinces = [...group.provinces].sort((a, b) => a - b);
|
|
179
|
+
const set = new Set(provinces);
|
|
180
|
+
const data = cloneState(primary);
|
|
181
|
+
data.provinces = provinces;
|
|
182
|
+
data.manpower = 0;
|
|
183
|
+
data.resources = new Map();
|
|
184
|
+
data.stateBuildings = new Map();
|
|
185
|
+
for (const id of parts) {
|
|
186
|
+
const part = contributions[position].get(id);
|
|
187
|
+
data.manpower += part.manpower;
|
|
188
|
+
for (const [resource, amount] of part.resources)
|
|
189
|
+
data.resources.set(resource, (data.resources.get(resource) ?? 0) + amount);
|
|
190
|
+
for (const [building, amount] of part.buildings)
|
|
191
|
+
data.stateBuildings.set(building, isLevelStateBuilding(index, building)
|
|
192
|
+
? Math.max(data.stateBuildings.get(building) ?? 0, amount)
|
|
193
|
+
: (data.stateBuildings.get(building) ?? 0) + amount);
|
|
194
|
+
}
|
|
195
|
+
for (const [resource, amount] of data.resources)
|
|
196
|
+
data.resources.set(resource, Math.round(amount * 100) / 100);
|
|
197
|
+
data.victoryPoints = sources.flatMap((state) => state.victoryPoints
|
|
198
|
+
.filter(({ provinceId }) => set.has(provinceId))
|
|
199
|
+
.map(({ provinceId, value }) => ({ provinceId, value })));
|
|
200
|
+
data.provinceBuildings = new Map(sources.flatMap((state) => [...state.provinceBuildings]
|
|
201
|
+
.filter(([provinceId]) => set.has(provinceId))
|
|
202
|
+
.map(([provinceId, values]) => [provinceId, new Map(values)])));
|
|
203
|
+
data.cores = uniqueStrings(sources.flatMap((state) => state.cores));
|
|
204
|
+
data.claims = uniqueStrings(sources.flatMap((state) => state.claims));
|
|
205
|
+
if (group.cores !== undefined)
|
|
206
|
+
data.cores = uniqueStrings(group.cores);
|
|
207
|
+
if (group.claims !== undefined)
|
|
208
|
+
data.claims = uniqueStrings(group.claims);
|
|
209
|
+
if (group.addCores !== undefined)
|
|
210
|
+
data.cores = uniqueStrings([...data.cores, ...group.addCores]);
|
|
211
|
+
if (group.addClaims !== undefined)
|
|
212
|
+
data.claims = uniqueStrings([...data.claims, ...group.addClaims]);
|
|
213
|
+
if (group.owner !== undefined)
|
|
214
|
+
data.owner = group.owner ?? undefined;
|
|
215
|
+
if (group.controller !== undefined)
|
|
216
|
+
data.controller = group.controller ?? undefined;
|
|
217
|
+
const finalId = stateIds[position];
|
|
218
|
+
for (const province of provinces)
|
|
219
|
+
provinceState.set(province, finalId);
|
|
220
|
+
if (heirId !== undefined) {
|
|
221
|
+
const heir = affected.get(heirId);
|
|
222
|
+
const fields = {
|
|
223
|
+
manpower: true,
|
|
224
|
+
resources: true,
|
|
225
|
+
provinces: true,
|
|
226
|
+
victoryPoints: true,
|
|
227
|
+
buildings: true,
|
|
228
|
+
};
|
|
229
|
+
if (!sameList(data.cores, uniqueStrings(heir.cores)))
|
|
230
|
+
fields.cores = true;
|
|
231
|
+
if (!sameList(data.claims, uniqueStrings(heir.claims)))
|
|
232
|
+
fields.claims = true;
|
|
233
|
+
if (data.owner !== heir.owner)
|
|
234
|
+
fields.owner = true;
|
|
235
|
+
if (data.controller !== heir.controller)
|
|
236
|
+
fields.controller = true;
|
|
237
|
+
commitState(changes, heir, data, fields, operationId);
|
|
238
|
+
changed.push(heirId);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// A new state: the largest source's history, with its undated blocks, under a new identifier.
|
|
242
|
+
assertDatedStateHistorySafe(primary, { provinces: true }, operationId);
|
|
243
|
+
const dated = [];
|
|
244
|
+
if (primary.historyBlock !== undefined)
|
|
245
|
+
for (const entry of assignments(primary.historyBlock))
|
|
246
|
+
if (entry.value.type === 'block' && parseMapDate(entry.key.value) !== undefined)
|
|
247
|
+
dated.push(primary.document.text.slice(entry.start, entry.end));
|
|
248
|
+
data.id = finalId;
|
|
249
|
+
data.name = `STATE_${finalId}`;
|
|
250
|
+
data.datedHistory = dated;
|
|
251
|
+
let relativePath = `${statesRoot}/${finalId}-AGENT_STATE.txt`;
|
|
252
|
+
for (let suffix = 2;; suffix += 1) {
|
|
253
|
+
const key = normalizeRelative(relativePath);
|
|
254
|
+
if (!index.activeFiles.byRelativePath.has(key) && !reservedFiles.has(key))
|
|
255
|
+
break;
|
|
256
|
+
relativePath = `${statesRoot}/${finalId}-AGENT_STATE_${suffix}.txt`;
|
|
257
|
+
}
|
|
258
|
+
reservedFiles.add(normalizeRelative(relativePath));
|
|
259
|
+
addChange(changes, relativePath, compileState(data, primary.document.newline), operationId, 'text/plain');
|
|
260
|
+
created.push(finalId);
|
|
261
|
+
const label = index.localisationByKey.get(`l_english:${primary.name}`)?.[0]?.entry.value ??
|
|
262
|
+
`State ${primary.id}`;
|
|
263
|
+
const ordinal = (nameCounts.get(primary.id) ?? 1) + 1;
|
|
264
|
+
nameCounts.set(primary.id, ordinal);
|
|
265
|
+
names.push([`STATE_${finalId}`, `${label} (${ordinal})`]);
|
|
266
|
+
}
|
|
267
|
+
// Dated history of a heir that loses land is refused by `commitState` above; removed states go.
|
|
268
|
+
for (const state of removedStates)
|
|
269
|
+
addChange(changes, state.file.relativePath, removalContent(index, state.file, `State ${state.id} regrouped into other states`), operationId, 'text/plain');
|
|
270
|
+
remapPositions(index, changes, provinceState, operationId);
|
|
271
|
+
upsertStateNames(index, changes, names, operationId);
|
|
272
|
+
if (options.remapReferences !== undefined) {
|
|
273
|
+
const mapping = [];
|
|
274
|
+
for (const state of affected.values()) {
|
|
275
|
+
const takers = groups.flatMap((_, position) => overlap[position].has(state.id) ? [position] : []);
|
|
276
|
+
const targets = [...new Set(takers.map((position) => stateIds[position]))];
|
|
277
|
+
const heirGroup = groupOfHeir.get(state.id);
|
|
278
|
+
const keepsId = heirGroup !== undefined;
|
|
279
|
+
const others = targets.filter((id) => id !== state.id);
|
|
280
|
+
if (keepsId && others.length === 0)
|
|
281
|
+
continue;
|
|
282
|
+
mapping.push({
|
|
283
|
+
from: state.id,
|
|
284
|
+
to: keepsId
|
|
285
|
+
? [state.id, ...others]
|
|
286
|
+
: takers.length === 1
|
|
287
|
+
? stateIds[takers[0]]
|
|
288
|
+
: (() => {
|
|
289
|
+
const best = takers
|
|
290
|
+
.slice()
|
|
291
|
+
.sort((a, b) => overlap[b].get(state.id) - overlap[a].get(state.id) || a - b)[0];
|
|
292
|
+
return [stateIds[best], ...targets.filter((id) => id !== stateIds[best])];
|
|
293
|
+
})(),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (mapping.length > 0)
|
|
297
|
+
remapScriptReferences(base, changes, operationId, {
|
|
298
|
+
entity: 'state',
|
|
299
|
+
mapping,
|
|
300
|
+
readOnlySources: options.remapReferences,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
changed: changed.sort((a, b) => a - b),
|
|
305
|
+
created,
|
|
306
|
+
removed: removedStates.map(({ id }) => id).sort((a, b) => a - b),
|
|
307
|
+
heirs: [...groupOfHeir]
|
|
308
|
+
.map(([stateId, group]) => ({ stateId, group }))
|
|
309
|
+
.sort((a, b) => a.stateId - b.stateId),
|
|
310
|
+
stateIds,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/** Point every building position at the state that now holds the province it lies in. */
|
|
314
|
+
function remapPositions(index, changes, provinceState, operationId) {
|
|
315
|
+
const documents = new Map(index.buildingPositions.map((record) => [record.document.file.relativePath, record.document]));
|
|
316
|
+
for (const [relativePath, document] of documents) {
|
|
317
|
+
const replacements = index.buildingPositions
|
|
318
|
+
.filter((record) => record.document === document)
|
|
319
|
+
.flatMap((record) => {
|
|
320
|
+
const provinceId = index.provinceAtMapCoordinate(record.x, record.z);
|
|
321
|
+
const target = provinceId === undefined ? undefined : provinceState.get(provinceId);
|
|
322
|
+
if (target === undefined || target === record.stateId)
|
|
323
|
+
return [];
|
|
324
|
+
const line = document.lines[record.line - 1];
|
|
325
|
+
return line === undefined
|
|
326
|
+
? []
|
|
327
|
+
: [
|
|
328
|
+
{
|
|
329
|
+
start: line.start,
|
|
330
|
+
end: line.end,
|
|
331
|
+
text: buildingPositionLine({ ...record, stateId: target }),
|
|
332
|
+
description: 'Remap building-position state reference',
|
|
333
|
+
},
|
|
334
|
+
];
|
|
335
|
+
});
|
|
336
|
+
if (replacements.length > 0)
|
|
337
|
+
addChange(changes, relativePath, applyTextRanges(document, replacements), operationId, 'text/plain');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** English names for new states: existing keys in mod files are replaced, others are appended. */
|
|
341
|
+
function upsertStateNames(index, changes, entries, operationId) {
|
|
342
|
+
if (entries.length === 0)
|
|
343
|
+
return;
|
|
344
|
+
const defaultFile = `${index.sourceRoots.localisation[0] ?? 'localisation'}/english/hoi4_agent_map_l_english.yml`;
|
|
345
|
+
const working = new Map();
|
|
346
|
+
const current = (relativePath) => {
|
|
347
|
+
const key = normalizeRelative(relativePath);
|
|
348
|
+
const staged = working.get(key);
|
|
349
|
+
if (staged !== undefined)
|
|
350
|
+
return staged;
|
|
351
|
+
const change = changes.get(key);
|
|
352
|
+
if (change !== undefined)
|
|
353
|
+
return change.content ?? undefined;
|
|
354
|
+
return index.activeFiles.byRelativePath.get(key)?.bytes;
|
|
355
|
+
};
|
|
356
|
+
const paths = new Map();
|
|
357
|
+
for (const [key, value] of entries) {
|
|
358
|
+
const existing = index.localisationByKey.get(`l_english:${key}`) ?? [];
|
|
359
|
+
if (existing.length > 1)
|
|
360
|
+
throw new ServiceError('MAP_STATE_LOCALISATION_AMBIGUOUS', `English localisation key ${key} has multiple active definitions`, { key });
|
|
361
|
+
const target = existing[0]?.file.rootKind === 'mod' ? existing[0].file.relativePath : defaultFile;
|
|
362
|
+
const bytes = current(target);
|
|
363
|
+
paths.set(normalizeRelative(target), target);
|
|
364
|
+
if (bytes === undefined) {
|
|
365
|
+
working.set(normalizeRelative(target), encodeSource(`l_english:\n${key}: "${escapedLocalisationValue(value)}"\n`, 'utf8-bom'));
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const document = parseLocalisation(bytes, target);
|
|
369
|
+
requireSafeLocalisationDocument(document, target);
|
|
370
|
+
const entry = document.entries.find((candidate) => candidate.key === key && candidate.language === 'l_english');
|
|
371
|
+
if (entry === undefined)
|
|
372
|
+
working.set(normalizeRelative(target), appendLocalisationEntry(document, key, value));
|
|
373
|
+
else
|
|
374
|
+
working.set(normalizeRelative(target), encodeSource(`${document.text.slice(0, entry.start)}${localisationEntryLine(document, entry, value)}${document.text.slice(entry.end)}`, 'utf8-bom'));
|
|
375
|
+
}
|
|
376
|
+
for (const [key, bytes] of [...working].sort(([left], [right]) => compareCodeUnits(left, right)))
|
|
377
|
+
addChange(changes, paths.get(key), bytes, operationId, 'text/yaml');
|
|
378
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
import { BORDER_BARRIER_MASK, BORDER_FEATURE_BITS, BORDER_PROVINCE_KIND, } from './border-quality.js';
|
|
3
|
+
import { buildProvinceGraph } from './partition-graph.js';
|
|
4
|
+
/**
|
|
5
|
+
* The graph territory assignment grows countries over (ADR 0060). Provinces come from a border
|
|
6
|
+
* edge table of the border metric (ADR 0059), so the natural lines are exactly the ones the metric rewards:
|
|
7
|
+
* every shared border carries the strength of the features it lies on and the number of its pixel
|
|
8
|
+
* edges that count as natural. States (or any other grouping of provinces) are aggregated into
|
|
9
|
+
* atoms with the same two numbers, and the algorithm in `territory.ts` runs on atoms only.
|
|
10
|
+
*/
|
|
11
|
+
/** Weight of a natural feature when it forms part of a border, 0 (none) to 1 (impassable line). */
|
|
12
|
+
export const TERRITORY_FEATURE_STRENGTH = {
|
|
13
|
+
river: 1,
|
|
14
|
+
extra: 1,
|
|
15
|
+
slope: 0.8,
|
|
16
|
+
terrainChange: 0.45,
|
|
17
|
+
lakeShore: 0.35,
|
|
18
|
+
};
|
|
19
|
+
/** Credit of a border edge towards natural border length: barrier features full, terrain half. */
|
|
20
|
+
const BARRIER_CREDIT = 1;
|
|
21
|
+
const TERRAIN_CREDIT = 0.5;
|
|
22
|
+
/** Strength of one border edge from its feature bits (the strongest feature wins). */
|
|
23
|
+
export function borderEdgeStrength(bits) {
|
|
24
|
+
let strength = 0;
|
|
25
|
+
if ((bits & BORDER_FEATURE_BITS.river) !== 0)
|
|
26
|
+
strength = Math.max(strength, TERRITORY_FEATURE_STRENGTH.river);
|
|
27
|
+
if ((bits & BORDER_FEATURE_BITS.extra) !== 0)
|
|
28
|
+
strength = Math.max(strength, TERRITORY_FEATURE_STRENGTH.extra);
|
|
29
|
+
if ((bits & BORDER_FEATURE_BITS.slope) !== 0)
|
|
30
|
+
strength = Math.max(strength, TERRITORY_FEATURE_STRENGTH.slope);
|
|
31
|
+
if ((bits & BORDER_FEATURE_BITS.terrainChange) !== 0)
|
|
32
|
+
strength = Math.max(strength, TERRITORY_FEATURE_STRENGTH.terrainChange);
|
|
33
|
+
if ((bits & BORDER_FEATURE_BITS.lakeShore) !== 0)
|
|
34
|
+
strength = Math.max(strength, TERRITORY_FEATURE_STRENGTH.lakeShore);
|
|
35
|
+
return strength;
|
|
36
|
+
}
|
|
37
|
+
export function borderEdgeCredit(bits) {
|
|
38
|
+
if ((bits & BORDER_BARRIER_MASK) !== 0)
|
|
39
|
+
return BARRIER_CREDIT;
|
|
40
|
+
if ((bits & BORDER_FEATURE_BITS.terrainChange) !== 0)
|
|
41
|
+
return TERRAIN_CREDIT;
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
function graphKind(kind) {
|
|
45
|
+
return kind === BORDER_PROVINCE_KIND.land ? 1 : kind === BORDER_PROVINCE_KIND.lake ? 2 : 0;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the province graph of a map from the border edge table of the border metric (ADR 0059). The table holds
|
|
49
|
+
* every land border pixel edge with its natural features, so the mean strength and the natural
|
|
50
|
+
* length of each shared border are exact; borders with water have none.
|
|
51
|
+
*/
|
|
52
|
+
export function buildTerritoryProvinceGraph(table) {
|
|
53
|
+
const { width, height, provinceIds, provinceKinds } = table;
|
|
54
|
+
const count = provinceKinds.length;
|
|
55
|
+
if (count > 65_535)
|
|
56
|
+
throw new ServiceError('MAP_TERRITORY_TOO_MANY_PROVINCES', 'Territory assignment supports at most 65,535 provinces', { provinces: count });
|
|
57
|
+
const size = new Uint32Array(count);
|
|
58
|
+
const sumX = new Float64Array(count);
|
|
59
|
+
const sumY = new Float64Array(count);
|
|
60
|
+
let negative = false;
|
|
61
|
+
for (let y = 0; y < height; y += 1) {
|
|
62
|
+
const row = y * width;
|
|
63
|
+
for (let x = 0; x < width; x += 1) {
|
|
64
|
+
const id = provinceIds[row + x];
|
|
65
|
+
if (id < 0 || id >= count) {
|
|
66
|
+
negative = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
size[id] += 1;
|
|
70
|
+
sumX[id] += x;
|
|
71
|
+
sumY[id] += y;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let pixel = provinceIds;
|
|
75
|
+
if (negative) {
|
|
76
|
+
pixel = new Int32Array(provinceIds.length);
|
|
77
|
+
for (let p = 0; p < pixel.length; p += 1) {
|
|
78
|
+
const id = provinceIds[p];
|
|
79
|
+
pixel[p] = id < 0 || id >= count ? 0 : id;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const graph = buildProvinceGraph({
|
|
83
|
+
width,
|
|
84
|
+
height,
|
|
85
|
+
pixel,
|
|
86
|
+
kind: Uint8Array.from(provinceKinds, graphKind),
|
|
87
|
+
size,
|
|
88
|
+
sumX,
|
|
89
|
+
sumY,
|
|
90
|
+
});
|
|
91
|
+
const credit = new Float32Array(graph.to.length);
|
|
92
|
+
const strength = new Float64Array(graph.to.length);
|
|
93
|
+
const slotOf = (from, to) => {
|
|
94
|
+
let low = graph.offsets[from];
|
|
95
|
+
let high = graph.offsets[from + 1] - 1;
|
|
96
|
+
while (low <= high) {
|
|
97
|
+
const middle = (low + high) >> 1;
|
|
98
|
+
const value = graph.to[middle];
|
|
99
|
+
if (value === to)
|
|
100
|
+
return middle;
|
|
101
|
+
if (value < to)
|
|
102
|
+
low = middle + 1;
|
|
103
|
+
else
|
|
104
|
+
high = middle - 1;
|
|
105
|
+
}
|
|
106
|
+
return -1;
|
|
107
|
+
};
|
|
108
|
+
for (let index = 0; index < table.edgeCount; index += 1) {
|
|
109
|
+
const a = table.provinceA[index];
|
|
110
|
+
const b = table.provinceB[index];
|
|
111
|
+
const bits = table.features[index];
|
|
112
|
+
const forward = slotOf(a, b);
|
|
113
|
+
const backward = slotOf(b, a);
|
|
114
|
+
if (forward < 0 || backward < 0)
|
|
115
|
+
continue;
|
|
116
|
+
const edgeCredit = borderEdgeCredit(bits);
|
|
117
|
+
const edgeStrength = borderEdgeStrength(bits);
|
|
118
|
+
credit[forward] += edgeCredit;
|
|
119
|
+
credit[backward] += edgeCredit;
|
|
120
|
+
strength[forward] += edgeStrength;
|
|
121
|
+
strength[backward] += edgeStrength;
|
|
122
|
+
}
|
|
123
|
+
for (let slot = 0; slot < strength.length; slot += 1)
|
|
124
|
+
graph.barrier[slot] = strength[slot] / Math.max(1, graph.length[slot]);
|
|
125
|
+
return { graph, credit, table, sumX, sumY, size };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The province graph of a partition (ADR 0058) as a territory province graph: its mean barrier
|
|
129
|
+
* strength per border gives the natural length (a border at full strength counts as natural).
|
|
130
|
+
* Used when the border edge table does not exist yet, as during world generation.
|
|
131
|
+
*/
|
|
132
|
+
export function territoryGraphFromPartition(graph, sumX, sumY) {
|
|
133
|
+
const credit = new Float32Array(graph.to.length);
|
|
134
|
+
for (let slot = 0; slot < credit.length; slot += 1)
|
|
135
|
+
credit[slot] = graph.length[slot] * Math.min(1, graph.barrier[slot] * 1.25);
|
|
136
|
+
return {
|
|
137
|
+
graph,
|
|
138
|
+
credit,
|
|
139
|
+
sumX: Float64Array.from({ length: graph.count }, (_, i) => sumX[i]),
|
|
140
|
+
sumY: Float64Array.from({ length: graph.count }, (_, i) => sumY[i]),
|
|
141
|
+
size: graph.size,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Aggregate provinces into atoms. `groups[i]` lists the provinces of atom `i`; every province must
|
|
146
|
+
* be land and appear once. `region` is optional and gives the group id of each atom.
|
|
147
|
+
*/
|
|
148
|
+
export function buildTerritoryAtoms(base, groups, region) {
|
|
149
|
+
const provinceCount = base.graph.count;
|
|
150
|
+
const atomOfProvince = new Int32Array(provinceCount).fill(-1);
|
|
151
|
+
const members = [];
|
|
152
|
+
for (const [atom, group] of groups.entries()) {
|
|
153
|
+
const sorted = [...group].sort((x, y) => x - y);
|
|
154
|
+
for (const province of sorted) {
|
|
155
|
+
if (province < 0 ||
|
|
156
|
+
province >= provinceCount ||
|
|
157
|
+
base.graph.kind[province] !== 1 ||
|
|
158
|
+
atomOfProvince[province] >= 0)
|
|
159
|
+
throw new ServiceError('MAP_TERRITORY_ATOM_INVALID', `Province ${province} cannot belong to atom ${atom}: it is not land or belongs to another atom`, { province, atom });
|
|
160
|
+
atomOfProvince[province] = atom;
|
|
161
|
+
}
|
|
162
|
+
members.push(sorted);
|
|
163
|
+
}
|
|
164
|
+
const count = members.length;
|
|
165
|
+
const size = new Uint32Array(count);
|
|
166
|
+
const sumX = new Float64Array(count);
|
|
167
|
+
const sumY = new Float64Array(count);
|
|
168
|
+
const rows = Array.from({ length: count }, () => new Map());
|
|
169
|
+
const { graph } = base;
|
|
170
|
+
for (let atom = 0; atom < count; atom += 1) {
|
|
171
|
+
const row = rows[atom];
|
|
172
|
+
for (const province of members[atom]) {
|
|
173
|
+
size[atom] += base.size[province];
|
|
174
|
+
sumX[atom] += base.sumX[province];
|
|
175
|
+
sumY[atom] += base.sumY[province];
|
|
176
|
+
for (let slot = graph.offsets[province]; slot < graph.offsets[province + 1]; slot += 1) {
|
|
177
|
+
const other = atomOfProvince[graph.to[slot]];
|
|
178
|
+
if (other < 0 || other === atom)
|
|
179
|
+
continue;
|
|
180
|
+
const length = graph.length[slot];
|
|
181
|
+
const entry = row.get(other);
|
|
182
|
+
if (entry === undefined)
|
|
183
|
+
row.set(other, [length, graph.barrier[slot] * length, base.credit[slot]]);
|
|
184
|
+
else {
|
|
185
|
+
entry[0] += length;
|
|
186
|
+
entry[1] += graph.barrier[slot] * length;
|
|
187
|
+
entry[2] += base.credit[slot];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const offsets = new Int32Array(count + 1);
|
|
193
|
+
for (let atom = 0; atom < count; atom += 1)
|
|
194
|
+
offsets[atom + 1] = offsets[atom] + rows[atom].size;
|
|
195
|
+
const to = new Int32Array(offsets[count]);
|
|
196
|
+
const length = new Uint32Array(offsets[count]);
|
|
197
|
+
const barrier = new Float32Array(offsets[count]);
|
|
198
|
+
const credit = new Float32Array(offsets[count]);
|
|
199
|
+
for (let atom = 0; atom < count; atom += 1) {
|
|
200
|
+
let slot = offsets[atom];
|
|
201
|
+
for (const other of [...rows[atom].keys()].sort((x, y) => x - y)) {
|
|
202
|
+
const [pairLength, pairStrength, pairCredit] = rows[atom].get(other);
|
|
203
|
+
to[slot] = other;
|
|
204
|
+
length[slot] = pairLength;
|
|
205
|
+
barrier[slot] = pairStrength / Math.max(1, pairLength);
|
|
206
|
+
credit[slot] = pairCredit;
|
|
207
|
+
slot += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const centerX = new Float32Array(count);
|
|
211
|
+
const centerY = new Float32Array(count);
|
|
212
|
+
for (let atom = 0; atom < count; atom += 1) {
|
|
213
|
+
const n = Math.max(1, size[atom]);
|
|
214
|
+
centerX[atom] = sumX[atom] / n;
|
|
215
|
+
centerY[atom] = sumY[atom] / n;
|
|
216
|
+
}
|
|
217
|
+
const atomGraph = {
|
|
218
|
+
count,
|
|
219
|
+
kind: new Uint8Array(count).fill(1),
|
|
220
|
+
offsets,
|
|
221
|
+
to,
|
|
222
|
+
length,
|
|
223
|
+
barrier,
|
|
224
|
+
centerX,
|
|
225
|
+
centerY,
|
|
226
|
+
width: graph.width,
|
|
227
|
+
height: graph.height,
|
|
228
|
+
size,
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
count,
|
|
232
|
+
graph: atomGraph,
|
|
233
|
+
credit,
|
|
234
|
+
members,
|
|
235
|
+
atomOfProvince,
|
|
236
|
+
region: region ?? new Int32Array(count).fill(-1),
|
|
237
|
+
};
|
|
238
|
+
}
|