hoi4-agent-tools 2.5.3 → 2.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 +6 -0
- package/README.md +5 -5
- package/dist/hoi4_agent_tools/map/catalog.js +398 -0
- package/dist/hoi4_agent_tools/map/index.js +1 -0
- package/dist/hoi4_agent_tools/map/operations.js +337 -30
- package/dist/hoi4_agent_tools/map/render.js +40 -147
- package/dist/hoi4_agent_tools/map/service.js +1 -0
- package/dist/hoi4_agent_tools/mcp/tools/map.js +85 -9
- package/dist/hoi4_agent_tools/schemas/map.js +69 -2
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/map.md +60 -23
- 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/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/map-operation.schema.json +228 -74
- package/schemas/operation-result.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/server.json +2 -2
|
@@ -170,6 +170,24 @@ function patchState(state, data, fields) {
|
|
|
170
170
|
const stateInsertions = [];
|
|
171
171
|
const newline = state.document.newline;
|
|
172
172
|
const stateIndent = lineIndent(state.document.text, state.assignment.start);
|
|
173
|
+
const patchStateScalar = (key, value, description) => {
|
|
174
|
+
const assignment = assignmentFor(state.block, key);
|
|
175
|
+
if (assignment?.value.type === 'scalar') {
|
|
176
|
+
replacements.push({
|
|
177
|
+
start: assignment.value.start,
|
|
178
|
+
end: assignment.value.end,
|
|
179
|
+
text: value,
|
|
180
|
+
description,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
stateInsertions.push(`${key} = ${value}`);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
if (fields.id === true)
|
|
188
|
+
patchStateScalar('id', numberText(data.id), 'Update state ID');
|
|
189
|
+
if (fields.name === true)
|
|
190
|
+
patchStateScalar('name', quote(data.name), 'Update state name key');
|
|
173
191
|
if (fields.manpower === true) {
|
|
174
192
|
const assignment = assignmentFor(state.block, 'manpower');
|
|
175
193
|
if (assignment?.value.type === 'scalar') {
|
|
@@ -311,19 +329,42 @@ function patchState(state, data, fields) {
|
|
|
311
329
|
}
|
|
312
330
|
return applyReplacements(state.document, replacements);
|
|
313
331
|
}
|
|
314
|
-
function patchRegion(region, provinces) {
|
|
315
|
-
const
|
|
332
|
+
function patchRegion(region, provinces, identity, patchProvinces = true) {
|
|
333
|
+
const replacements = [];
|
|
316
334
|
const indent = lineIndent(region.document.text, region.assignment.start);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
335
|
+
if (patchProvinces) {
|
|
336
|
+
const assignment = assignmentFor(region.block, 'provinces');
|
|
337
|
+
const rendered = `provinces = ${renderNumberList(provinces, `${indent}\t`, region.document.newline)}`;
|
|
338
|
+
replacements.push(assignment === undefined
|
|
339
|
+
? blockInsertion(region.block, region.document.text, rendered, region.document.newline)
|
|
340
|
+
: {
|
|
341
|
+
start: assignment.start,
|
|
342
|
+
end: assignment.end,
|
|
343
|
+
text: rendered,
|
|
344
|
+
description: 'Update strategic region provinces',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
for (const [key, value] of [
|
|
348
|
+
['id', identity?.id],
|
|
349
|
+
['name', identity?.name],
|
|
350
|
+
]) {
|
|
351
|
+
if (value === undefined)
|
|
352
|
+
continue;
|
|
353
|
+
const renderedValue = key === 'name' ? quote(value) : numberText(value);
|
|
354
|
+
const scalar = assignmentFor(region.block, key);
|
|
355
|
+
if (scalar?.value.type === 'scalar') {
|
|
356
|
+
replacements.push({
|
|
357
|
+
start: scalar.value.start,
|
|
358
|
+
end: scalar.value.end,
|
|
359
|
+
text: renderedValue,
|
|
360
|
+
description: `Update strategic region ${key}`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
replacements.push(blockInsertion(region.block, region.document.text, `${key} = ${renderedValue}`, region.document.newline));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return applyReplacements(region.document, replacements);
|
|
327
368
|
}
|
|
328
369
|
function definitionLine(definition) {
|
|
329
370
|
return [
|
|
@@ -743,8 +784,26 @@ function requireMovePolicy(operation) {
|
|
|
743
784
|
}
|
|
744
785
|
}
|
|
745
786
|
function requireSplitPolicy(operation) {
|
|
746
|
-
const
|
|
747
|
-
|
|
787
|
+
const requested = operation.distribution;
|
|
788
|
+
if (operation.kind === 'create_state' &&
|
|
789
|
+
(requested === undefined || requested === 'proportional')) {
|
|
790
|
+
return {
|
|
791
|
+
manpower: { method: 'proportional-by-land-pixels' },
|
|
792
|
+
resources: { method: 'proportional-by-land-pixels' },
|
|
793
|
+
stateBuildings: { method: 'proportional-by-land-pixels' },
|
|
794
|
+
owner: { method: 'copy-source' },
|
|
795
|
+
controller: { method: 'copy-source' },
|
|
796
|
+
cores: { method: 'copy-source' },
|
|
797
|
+
claims: { method: 'copy-source' },
|
|
798
|
+
victoryPoints: 'follow-province',
|
|
799
|
+
provinceBuildings: 'follow-province',
|
|
800
|
+
ports: 'follow-province',
|
|
801
|
+
supplyNodes: 'follow-province',
|
|
802
|
+
railways: 'follow-province',
|
|
803
|
+
positions: 'follow-province',
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
const policy = requested;
|
|
748
807
|
if (policy?.manpower === undefined ||
|
|
749
808
|
policy.resources === undefined ||
|
|
750
809
|
policy.stateBuildings === undefined ||
|
|
@@ -1152,10 +1211,14 @@ function applyMoveStateProvinces(base, changes, operation) {
|
|
|
1152
1211
|
function* applySplitState(base, changes, allocations, operation, signal) {
|
|
1153
1212
|
const policy = requireSplitPolicy(operation);
|
|
1154
1213
|
const index = currentIndex(base, changes);
|
|
1155
|
-
const source = index.statesById.get(operation.sourceStateId);
|
|
1156
|
-
if (source === undefined)
|
|
1157
|
-
throw new ServiceError('MAP_STATE_NOT_FOUND', `State ${operation.sourceStateId} does not exist`);
|
|
1158
1214
|
const selected = uniqueSorted(operation.provinceIds);
|
|
1215
|
+
const inferredSourceIds = uniqueSorted(selected.flatMap((provinceId) => index.stateForProvince(provinceId).map(({ id }) => id)));
|
|
1216
|
+
const sourceStateId = operation.sourceStateId ?? (inferredSourceIds.length === 1 ? inferredSourceIds[0] : undefined);
|
|
1217
|
+
if (sourceStateId === undefined)
|
|
1218
|
+
throw new ServiceError('MAP_STATE_SOURCE_AMBIGUOUS', 'Source state could not be inferred because the selected provinces do not all belong to one state', { provinceIds: selected, candidateStateIds: inferredSourceIds });
|
|
1219
|
+
const source = index.statesById.get(sourceStateId);
|
|
1220
|
+
if (source === undefined)
|
|
1221
|
+
throw new ServiceError('MAP_STATE_NOT_FOUND', `State ${sourceStateId} does not exist`);
|
|
1159
1222
|
const selectedSet = new Set(selected);
|
|
1160
1223
|
if (selected.length === 0 || selected.some((id) => !source.provinces.includes(id))) {
|
|
1161
1224
|
throw new ServiceError('MAP_STATE_PROVINCE_SELECTION_INVALID', 'Every new-state province must belong to the source state');
|
|
@@ -1172,10 +1235,29 @@ function* applySplitState(base, changes, allocations, operation, signal) {
|
|
|
1172
1235
|
const sourceData = cloneState(source);
|
|
1173
1236
|
const destinationData = cloneState(source);
|
|
1174
1237
|
destinationData.id = allocation.id;
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1238
|
+
const requestedKey = operation.name?.trim();
|
|
1239
|
+
const displayName = operation.displayName?.trim();
|
|
1240
|
+
if ((requestedKey === undefined || requestedKey === '') &&
|
|
1241
|
+
(displayName === undefined || displayName === ''))
|
|
1242
|
+
throw new ServiceError('MAP_STATE_NAME_REQUIRED', 'State creation requires name or displayName');
|
|
1243
|
+
destinationData.name =
|
|
1244
|
+
requestedKey === undefined || requestedKey === '' ? `STATE_${allocation.id}` : requestedKey;
|
|
1245
|
+
const operationWithLocalisation = displayName !== undefined && displayName !== '' && operation.localisation === undefined
|
|
1246
|
+
? {
|
|
1247
|
+
...operation,
|
|
1248
|
+
localisation: {
|
|
1249
|
+
method: 'upsert',
|
|
1250
|
+
language: 'l_english',
|
|
1251
|
+
value: displayName,
|
|
1252
|
+
...(index.localisationByKey.has(`l_english:${destinationData.name}`)
|
|
1253
|
+
? {}
|
|
1254
|
+
: {
|
|
1255
|
+
file: `${index.sourceRoots.localisation[0] ?? 'localisation'}/english/hoi4_agent_map_l_english.yml`,
|
|
1256
|
+
}),
|
|
1257
|
+
},
|
|
1258
|
+
}
|
|
1259
|
+
: operation;
|
|
1260
|
+
applyStateLocalisation(index, changes, operationWithLocalisation, destinationData.name);
|
|
1179
1261
|
sourceData.provinces = sourceData.provinces.filter((id) => !selectedSet.has(id));
|
|
1180
1262
|
destinationData.provinces = selected;
|
|
1181
1263
|
[sourceData.manpower, destinationData.manpower] = splitScalar(source.manpower, ratio, policy.manpower);
|
|
@@ -1408,6 +1490,33 @@ function* selectedPixels(index, provinceId, geometry, signal) {
|
|
|
1408
1490
|
}
|
|
1409
1491
|
return offsets.subarray(0, uniqueCount);
|
|
1410
1492
|
}
|
|
1493
|
+
if (geometry.kind === 'rectangle') {
|
|
1494
|
+
const { origin, width, height } = geometry;
|
|
1495
|
+
const count = width * height;
|
|
1496
|
+
if (!Number.isSafeInteger(width) ||
|
|
1497
|
+
!Number.isSafeInteger(height) ||
|
|
1498
|
+
width <= 0 ||
|
|
1499
|
+
height <= 0 ||
|
|
1500
|
+
origin.x < 0 ||
|
|
1501
|
+
origin.y < 0 ||
|
|
1502
|
+
origin.x + width > raster.width ||
|
|
1503
|
+
origin.y + height > raster.height)
|
|
1504
|
+
throw new ServiceError('MAP_GEOMETRY_OUT_OF_BOUNDS', 'Province rectangle must be a positive integer rectangle inside the active raster', { origin, width, height, raster: { width: raster.width, height: raster.height } });
|
|
1505
|
+
assertSelectedPixelBudget(count, 'Rectangle geometry');
|
|
1506
|
+
const offsets = new Uint32Array(count);
|
|
1507
|
+
let selectedIndex = 0;
|
|
1508
|
+
for (let y = origin.y; y < origin.y + height; y += 1) {
|
|
1509
|
+
yield* cancellationCheckpoint(signal, y - origin.y, 16);
|
|
1510
|
+
for (let x = origin.x; x < origin.x + width; x += 1) {
|
|
1511
|
+
const offset = y * raster.width + x;
|
|
1512
|
+
if (raster.provinceIds[offset] !== provinceId)
|
|
1513
|
+
throw new ServiceError('MAP_GEOMETRY_OUTSIDE_SOURCE', 'Every rectangle pixel must belong to the source province', { x, y, provinceId });
|
|
1514
|
+
offsets[selectedIndex] = offset;
|
|
1515
|
+
selectedIndex += 1;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return offsets;
|
|
1519
|
+
}
|
|
1411
1520
|
if (geometry.kind === 'mask') {
|
|
1412
1521
|
const offsets = yield* rasterMaskPixels(raster, geometry, signal);
|
|
1413
1522
|
for (let indexOffset = 0; indexOffset < offsets.length; indexOffset += 1) {
|
|
@@ -1518,17 +1627,18 @@ function* provincePixelOffsets(raster, provinceIds, source, signal) {
|
|
|
1518
1627
|
return offsets;
|
|
1519
1628
|
}
|
|
1520
1629
|
function definitionFromOperation(source, operation, id, allocatedColor) {
|
|
1521
|
-
const
|
|
1630
|
+
const definitionPolicy = operation.definition ?? { method: 'inherit-source' };
|
|
1631
|
+
const values = definitionPolicy.method === 'inherit-source'
|
|
1522
1632
|
? {
|
|
1523
|
-
type:
|
|
1524
|
-
coastal:
|
|
1525
|
-
terrain:
|
|
1526
|
-
continent:
|
|
1527
|
-
color:
|
|
1633
|
+
type: definitionPolicy.overrides?.type ?? source.type,
|
|
1634
|
+
coastal: definitionPolicy.overrides?.coastal ?? source.coastal,
|
|
1635
|
+
terrain: definitionPolicy.overrides?.terrain ?? source.terrain,
|
|
1636
|
+
continent: definitionPolicy.overrides?.continent ?? source.continent,
|
|
1637
|
+
color: definitionPolicy.overrides?.color ?? allocatedColor,
|
|
1528
1638
|
}
|
|
1529
1639
|
: {
|
|
1530
|
-
...
|
|
1531
|
-
color:
|
|
1640
|
+
...definitionPolicy.value,
|
|
1641
|
+
color: definitionPolicy.value.color ?? allocatedColor,
|
|
1532
1642
|
};
|
|
1533
1643
|
return {
|
|
1534
1644
|
id,
|
|
@@ -1542,7 +1652,22 @@ function definitionFromOperation(source, operation, id, allocatedColor) {
|
|
|
1542
1652
|
};
|
|
1543
1653
|
}
|
|
1544
1654
|
function requireSplitProvincePolicy(operation, source) {
|
|
1545
|
-
const
|
|
1655
|
+
const requested = operation.distribution;
|
|
1656
|
+
const policy = operation.kind === 'create_province' &&
|
|
1657
|
+
(requested === undefined || requested === 'inherit-source')
|
|
1658
|
+
? {
|
|
1659
|
+
state: source.type === 'land' ? 'inherit-source' : 'none',
|
|
1660
|
+
strategicRegion: 'inherit-source',
|
|
1661
|
+
victoryPoints: 'retain-source',
|
|
1662
|
+
provinceBuildings: 'retain-source',
|
|
1663
|
+
ports: 'retain-source',
|
|
1664
|
+
supplyNodes: 'retain-source',
|
|
1665
|
+
railways: 'retain-source',
|
|
1666
|
+
adjacencies: 'retain-source',
|
|
1667
|
+
positions: 'retain-source',
|
|
1668
|
+
entityLocators: 'retain-source',
|
|
1669
|
+
}
|
|
1670
|
+
: requested;
|
|
1546
1671
|
if (policy?.strategicRegion !== 'inherit-source' ||
|
|
1547
1672
|
policy.victoryPoints !== 'retain-source' ||
|
|
1548
1673
|
policy.provinceBuildings !== 'retain-source' ||
|
|
@@ -2579,6 +2704,186 @@ function remapReferenceFiles(index, changes, provinceIds, operationId) {
|
|
|
2579
2704
|
addChange(changes, document.file.relativePath, applyTextRanges(document, replacements), operationId, 'text/plain');
|
|
2580
2705
|
}
|
|
2581
2706
|
}
|
|
2707
|
+
function renumberMapping(existingIds, operation) {
|
|
2708
|
+
const ids = new Set(existingIds);
|
|
2709
|
+
if (!ids.has(operation.fromId))
|
|
2710
|
+
throw new ServiceError('MAP_ENTITY_ID_NOT_FOUND', `${operation.entity} ${operation.fromId} does not exist`);
|
|
2711
|
+
if (operation.fromId === operation.toId)
|
|
2712
|
+
throw new ServiceError('MAP_ENTITY_ID_UNCHANGED', `${operation.entity} already uses ID ${operation.toId}`);
|
|
2713
|
+
const targetOccupied = ids.has(operation.toId);
|
|
2714
|
+
if (targetOccupied && (operation.collision ?? 'swap') === 'reject')
|
|
2715
|
+
throw new ServiceError('MAP_ENTITY_ID_COLLISION', `${operation.entity} ID ${operation.toId} already exists`);
|
|
2716
|
+
return new Map([
|
|
2717
|
+
[operation.fromId, operation.toId],
|
|
2718
|
+
...(targetOccupied ? [[operation.toId, operation.fromId]] : []),
|
|
2719
|
+
]);
|
|
2720
|
+
}
|
|
2721
|
+
function renameLocalisationKeys(index, changes, keys, operationId) {
|
|
2722
|
+
if (keys.size === 0)
|
|
2723
|
+
return;
|
|
2724
|
+
for (const [from, to] of keys) {
|
|
2725
|
+
const destination = index.localisationEntries.filter(({ entry }) => entry.key === to);
|
|
2726
|
+
if (destination.length > 0 && !keys.has(to))
|
|
2727
|
+
throw new ServiceError('MAP_LOCALISATION_KEY_COLLISION', `Localisation key ${to} already exists`, { from, to });
|
|
2728
|
+
}
|
|
2729
|
+
const documents = new Map();
|
|
2730
|
+
for (const { file, document, entry } of index.localisationEntries) {
|
|
2731
|
+
if (file.rootKind !== 'mod')
|
|
2732
|
+
continue;
|
|
2733
|
+
const replacementKey = keys.get(entry.key);
|
|
2734
|
+
if (replacementKey === undefined)
|
|
2735
|
+
continue;
|
|
2736
|
+
requireSafeLocalisationDocument(document, file.relativePath);
|
|
2737
|
+
const group = documents.get(file.relativePath) ?? { document, replacements: [] };
|
|
2738
|
+
const line = document.text.slice(entry.start, entry.end);
|
|
2739
|
+
const keyOffset = line.indexOf(entry.key);
|
|
2740
|
+
if (keyOffset < 0)
|
|
2741
|
+
throw new ServiceError('MAP_LOCALISATION_KEY_SOURCE_INVALID', `Localisation source line does not contain key ${entry.key}`, { file: file.displayPath, line: entry.line });
|
|
2742
|
+
group.replacements.push({
|
|
2743
|
+
start: entry.start + keyOffset,
|
|
2744
|
+
end: entry.start + keyOffset + entry.key.length,
|
|
2745
|
+
text: replacementKey,
|
|
2746
|
+
description: 'Renumber map localisation key',
|
|
2747
|
+
});
|
|
2748
|
+
documents.set(file.relativePath, group);
|
|
2749
|
+
}
|
|
2750
|
+
for (const [relativePath, { document, replacements }] of documents) {
|
|
2751
|
+
const ordered = [...replacements].sort((left, right) => left.start - right.start);
|
|
2752
|
+
let cursor = 0;
|
|
2753
|
+
let text = '';
|
|
2754
|
+
for (const replacement of ordered) {
|
|
2755
|
+
if (replacement.start < cursor)
|
|
2756
|
+
throw new ServiceError('MAP_LOCALISATION_REPLACEMENT_OVERLAP', 'Map localisation key replacements overlap');
|
|
2757
|
+
text += document.text.slice(cursor, replacement.start) + replacement.text;
|
|
2758
|
+
cursor = replacement.end;
|
|
2759
|
+
}
|
|
2760
|
+
text += document.text.slice(cursor);
|
|
2761
|
+
addChange(changes, relativePath, encodeSource(text, 'utf8-bom'), operationId, 'text/yaml');
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
function remapBuildingPositionStateIds(index, changes, stateIds, operationId) {
|
|
2765
|
+
const documents = new Map(index.buildingPositions.map((record) => [record.document.file.relativePath, record.document]));
|
|
2766
|
+
for (const [relativePath, document] of documents) {
|
|
2767
|
+
const replacements = index.buildingPositions
|
|
2768
|
+
.filter((record) => record.document === document)
|
|
2769
|
+
.flatMap((record) => {
|
|
2770
|
+
const stateId = stateIds.get(record.stateId);
|
|
2771
|
+
const line = document.lines[record.line - 1];
|
|
2772
|
+
return stateId === undefined || line === undefined
|
|
2773
|
+
? []
|
|
2774
|
+
: [
|
|
2775
|
+
{
|
|
2776
|
+
start: line.start,
|
|
2777
|
+
end: line.end,
|
|
2778
|
+
text: replaceDelimitedFields(line.text, new Map([[0, numberText(stateId)]])),
|
|
2779
|
+
description: 'Renumber building-position state reference',
|
|
2780
|
+
},
|
|
2781
|
+
];
|
|
2782
|
+
});
|
|
2783
|
+
if (replacements.length > 0)
|
|
2784
|
+
addChange(changes, relativePath, applyTextRanges(document, replacements), operationId, 'text/plain');
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
function remapWeatherPositionRegionIds(index, changes, regionIds, operationId) {
|
|
2788
|
+
const documents = new Map(index.weatherPositions.map((record) => [record.document.file.relativePath, record.document]));
|
|
2789
|
+
for (const [relativePath, document] of documents) {
|
|
2790
|
+
const replacements = index.weatherPositions
|
|
2791
|
+
.filter((record) => record.document === document)
|
|
2792
|
+
.flatMap((record) => {
|
|
2793
|
+
const strategicRegionId = regionIds.get(record.strategicRegionId);
|
|
2794
|
+
const line = document.lines[record.line - 1];
|
|
2795
|
+
return strategicRegionId === undefined || line === undefined
|
|
2796
|
+
? []
|
|
2797
|
+
: [
|
|
2798
|
+
{
|
|
2799
|
+
start: line.start,
|
|
2800
|
+
end: line.end,
|
|
2801
|
+
text: replaceDelimitedFields(line.text, new Map([[0, numberText(strategicRegionId)]])),
|
|
2802
|
+
description: 'Renumber weather-position strategic-region reference',
|
|
2803
|
+
},
|
|
2804
|
+
];
|
|
2805
|
+
});
|
|
2806
|
+
if (replacements.length > 0)
|
|
2807
|
+
addChange(changes, relativePath, applyTextRanges(document, replacements), operationId, 'text/plain');
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
function applyRenumberMapEntity(base, changes, operation) {
|
|
2811
|
+
const index = currentIndex(base, changes);
|
|
2812
|
+
if (!Number.isSafeInteger(operation.fromId) || !Number.isSafeInteger(operation.toId))
|
|
2813
|
+
throw new ServiceError('MAP_ENTITY_ID_INVALID', 'Map IDs must be safe integers');
|
|
2814
|
+
if ((operation.entity === 'province' && (operation.fromId < 0 || operation.toId < 0)) ||
|
|
2815
|
+
(operation.entity !== 'province' && (operation.fromId <= 0 || operation.toId <= 0)))
|
|
2816
|
+
throw new ServiceError('MAP_ENTITY_ID_INVALID', 'Map entity ID is outside its valid range');
|
|
2817
|
+
if (operation.entity === 'province') {
|
|
2818
|
+
const mapping = renumberMapping(index.definitions.map(({ id }) => id), operation);
|
|
2819
|
+
const projectedIds = index.definitions.map(({ id }) => remapProvinceId(mapping, id));
|
|
2820
|
+
if (!contiguous(projectedIds, 0))
|
|
2821
|
+
throw new ServiceError('MAP_PROVINCE_ID_GAP', 'Province renumbering must preserve the contiguous zero-based ID table; use the default swap when the target is occupied', { projectedIds: uniqueSorted(projectedIds) });
|
|
2822
|
+
const definitionUpdates = new Map();
|
|
2823
|
+
for (const [fromId, toId] of mapping) {
|
|
2824
|
+
const definition = required(index.definitionsById.get(fromId), 'MAP_ENTITY_ID_NOT_FOUND', `Province ${fromId} does not exist`);
|
|
2825
|
+
definitionUpdates.set(fromId, { ...definition, id: toId });
|
|
2826
|
+
}
|
|
2827
|
+
addChange(changes, required(index.definitionFile, 'MAP_DEFINITION_FILE_MISSING', 'Definition file is missing')
|
|
2828
|
+
.relativePath, patchDefinitions(index, definitionUpdates, new Set(), []), operation.id, 'text/plain');
|
|
2829
|
+
for (const state of index.states) {
|
|
2830
|
+
const data = cloneState(state);
|
|
2831
|
+
const fields = remapStateProvinceData(data, mapping);
|
|
2832
|
+
if (Object.values(fields).some(Boolean))
|
|
2833
|
+
commitState(changes, state, data, fields, operation.id);
|
|
2834
|
+
}
|
|
2835
|
+
for (const region of index.regions) {
|
|
2836
|
+
if (!region.provinces.some((id) => mapping.has(id)))
|
|
2837
|
+
continue;
|
|
2838
|
+
commitRegion(changes, region, uniqueSorted(region.provinces.map((id) => remapProvinceId(mapping, id))), operation.id);
|
|
2839
|
+
}
|
|
2840
|
+
remapReferenceFiles(index, changes, mapping, operation.id);
|
|
2841
|
+
if (operation.renameLocalisation ?? true) {
|
|
2842
|
+
const keys = new Map();
|
|
2843
|
+
for (const [fromId, toId] of mapping) {
|
|
2844
|
+
keys.set(`PROV${fromId}`, `PROV${toId}`);
|
|
2845
|
+
keys.set(`VICTORY_POINTS_${fromId}`, `VICTORY_POINTS_${toId}`);
|
|
2846
|
+
}
|
|
2847
|
+
renameLocalisationKeys(currentIndex(base, changes), changes, keys, operation.id);
|
|
2848
|
+
}
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
if (operation.entity === 'state') {
|
|
2852
|
+
const mapping = renumberMapping(index.states.map(({ id }) => id), operation);
|
|
2853
|
+
const localisationKeys = new Map();
|
|
2854
|
+
for (const [fromId, toId] of mapping) {
|
|
2855
|
+
const state = required(index.statesById.get(fromId), 'MAP_ENTITY_ID_NOT_FOUND', `State ${fromId} does not exist`);
|
|
2856
|
+
const data = cloneState(state);
|
|
2857
|
+
data.id = toId;
|
|
2858
|
+
const standardName = state.name === `STATE_${fromId}`;
|
|
2859
|
+
if (standardName) {
|
|
2860
|
+
data.name = `STATE_${toId}`;
|
|
2861
|
+
localisationKeys.set(state.name, data.name);
|
|
2862
|
+
}
|
|
2863
|
+
commitState(changes, state, data, { id: true, ...(standardName ? { name: true } : {}) }, operation.id);
|
|
2864
|
+
}
|
|
2865
|
+
remapBuildingPositionStateIds(index, changes, mapping, operation.id);
|
|
2866
|
+
if (operation.renameLocalisation ?? true)
|
|
2867
|
+
renameLocalisationKeys(currentIndex(base, changes), changes, localisationKeys, operation.id);
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
const mapping = renumberMapping(index.regions.map(({ id }) => id), operation);
|
|
2871
|
+
const projectedIds = index.regions.map(({ id }) => mapping.get(id) ?? id);
|
|
2872
|
+
if (!contiguous(projectedIds, 1))
|
|
2873
|
+
throw new ServiceError('MAP_STRATEGIC_REGION_ID_GAP', 'Strategic-region renumbering must preserve the contiguous one-based ID table; use the default swap when the target is occupied', { projectedIds: uniqueSorted(projectedIds) });
|
|
2874
|
+
const localisationKeys = new Map();
|
|
2875
|
+
for (const [fromId, toId] of mapping) {
|
|
2876
|
+
const region = required(index.regionsById.get(fromId), 'MAP_ENTITY_ID_NOT_FOUND', `Strategic region ${fromId} does not exist`);
|
|
2877
|
+
const standardName = region.name === `STRATEGICREGION_${fromId}`;
|
|
2878
|
+
const name = standardName ? `STRATEGICREGION_${toId}` : region.name;
|
|
2879
|
+
if (standardName)
|
|
2880
|
+
localisationKeys.set(region.name, name);
|
|
2881
|
+
addChange(changes, region.file.relativePath, patchRegion(region, region.provinces, { id: toId, ...(standardName ? { name } : {}) }, false), operation.id, 'text/plain');
|
|
2882
|
+
}
|
|
2883
|
+
remapWeatherPositionRegionIds(index, changes, mapping, operation.id);
|
|
2884
|
+
if (operation.renameLocalisation ?? true)
|
|
2885
|
+
renameLocalisationKeys(currentIndex(base, changes), changes, localisationKeys, operation.id);
|
|
2886
|
+
}
|
|
2582
2887
|
function applyEntityLocator(base, changes, operation) {
|
|
2583
2888
|
const index = currentIndex(base, changes);
|
|
2584
2889
|
const matching = index.entityLocators.filter(({ entity, name }) => entity === operation.entity && name === operation.name);
|
|
@@ -2658,6 +2963,8 @@ function* planMapOperationSteps(base, operations, signal) {
|
|
|
2658
2963
|
expectedChangedBounds = mergeBounds(expectedChangedBounds, yield* applyNormalAdjacency(base, changes, operation, signal));
|
|
2659
2964
|
else if (operation.kind === 'update_entity_locator')
|
|
2660
2965
|
applyEntityLocator(base, changes, operation);
|
|
2966
|
+
else if (operation.kind === 'renumber_map_entity')
|
|
2967
|
+
applyRenumberMapEntity(base, changes, operation);
|
|
2661
2968
|
else
|
|
2662
2969
|
handleSimpleOperation(base, changes, operation);
|
|
2663
2970
|
}
|