hoi4-agent-tools 4.4.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 +24 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -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/model.js +2 -1
- package/dist/hoi4_agent_tools/map/operations.js +282 -38
- package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
- 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 +93 -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 +119 -1
- package/dist/hoi4_agent_tools/map/validation.js +4 -0
- package/dist/hoi4_agent_tools/map/world-context.js +23 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +400 -145
- package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
- package/dist/hoi4_agent_tools/schemas/map.js +277 -34
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/focus.md +6 -0
- package/docs/map.md +132 -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 +1541 -786
- 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
|
@@ -9,12 +9,14 @@ import { derivedStateCapital, encodeTextDocument, LEVEL_STATE_BUILDINGS, MapWork
|
|
|
9
9
|
import { planReferenceRemap, ReferenceRemapError } from './references.js';
|
|
10
10
|
import { generateWorld } from './world.js';
|
|
11
11
|
import { resolveEdge } from './world-edge.js';
|
|
12
|
+
import { DEFAULT_AUTO_STRAIT_GAP_PX } from './gameplay/crossings.js';
|
|
12
13
|
import { planMapPositions, planSupplyRebuild } from './derived.js';
|
|
13
|
-
import { buildWorldContext } from './world-context.js';
|
|
14
|
-
import {
|
|
14
|
+
import { buildWorldContext, countryTagCatalog, WORLD_COUNTRY_TAG_FILE, } from './world-context.js';
|
|
15
|
+
import { planWorldOutputs } from './world-outputs.js';
|
|
15
16
|
import { resolvePartitionMode } from './partition.js';
|
|
17
|
+
import { applyAssignTerritory, applyRepartitionStates, } from './territory-operations.js';
|
|
16
18
|
import { unsupportedGeographyParts } from './relief/index.js';
|
|
17
|
-
function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
|
|
19
|
+
export function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
|
|
18
20
|
if (iteration % stride !== 0)
|
|
19
21
|
return;
|
|
20
22
|
signal?.throwIfAborted();
|
|
@@ -41,7 +43,9 @@ async function completeCooperatively(steps, signal) {
|
|
|
41
43
|
yieldDeadline = performance.now() + 8;
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
|
-
|
|
46
|
+
/** Paths listed per kind in diagnostics and tool results; the artifact lists them all. */
|
|
47
|
+
export const WORLD_OUTPUT_PATH_SAMPLE = 200;
|
|
48
|
+
export function normalizeRelative(value) {
|
|
45
49
|
return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
|
|
46
50
|
}
|
|
47
51
|
function numberText(value) {
|
|
@@ -59,10 +63,10 @@ function required(value, code, message) {
|
|
|
59
63
|
function quote(value) {
|
|
60
64
|
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
61
65
|
}
|
|
62
|
-
function uniqueSorted(values) {
|
|
66
|
+
export function uniqueSorted(values) {
|
|
63
67
|
return [...new Set(values)].sort((left, right) => left - right);
|
|
64
68
|
}
|
|
65
|
-
function uniqueStrings(values) {
|
|
69
|
+
export function uniqueStrings(values) {
|
|
66
70
|
return [...new Set(values)].sort((left, right) => compareCodeUnits(left, right));
|
|
67
71
|
}
|
|
68
72
|
function lineIndent(text, offset) {
|
|
@@ -108,7 +112,7 @@ function blockInsertion(block, documentText, content, newline) {
|
|
|
108
112
|
description: 'Insert map source assignment',
|
|
109
113
|
};
|
|
110
114
|
}
|
|
111
|
-
function cloneState(state) {
|
|
115
|
+
export function cloneState(state) {
|
|
112
116
|
return {
|
|
113
117
|
id: state.id,
|
|
114
118
|
name: state.name,
|
|
@@ -143,7 +147,7 @@ function compileHistory(data, indent, newline) {
|
|
|
143
147
|
rows.push(...(data.datedHistory ?? []).map((block) => `${indent}\t${block}`));
|
|
144
148
|
return rows.length === 0 ? `{ }` : `{${newline}${rows.join(newline)}${newline}${indent}}`;
|
|
145
149
|
}
|
|
146
|
-
function compileState(data, newline = '\n') {
|
|
150
|
+
export function compileState(data, newline = '\n') {
|
|
147
151
|
const resources = data.resources.size === 0
|
|
148
152
|
? ''
|
|
149
153
|
: `\tresources = ${renderScalarMap(data.resources, '\t', newline)}${newline}`;
|
|
@@ -436,7 +440,7 @@ function patchDefinitionLine(source, updated) {
|
|
|
436
440
|
replacements.set(7, String(updated.continent));
|
|
437
441
|
return replaceDelimitedFields(line.text, replacements);
|
|
438
442
|
}
|
|
439
|
-
function applyTextRanges(document, replacements) {
|
|
443
|
+
export function applyTextRanges(document, replacements) {
|
|
440
444
|
const ordered = [...replacements].sort((left, right) => left.start - right.start || left.end - right.end);
|
|
441
445
|
let cursor = 0;
|
|
442
446
|
const pieces = [];
|
|
@@ -493,7 +497,7 @@ function patchDefinitions(index, updates, removals, additions) {
|
|
|
493
497
|
}
|
|
494
498
|
return applyTextRanges(document, replacements);
|
|
495
499
|
}
|
|
496
|
-
function rootEvidence(records) {
|
|
500
|
+
export function rootEvidence(records) {
|
|
497
501
|
const groups = new Map();
|
|
498
502
|
for (const record of records) {
|
|
499
503
|
const key = `${record.file.rootKind}:${record.file.loadOrder}`;
|
|
@@ -530,7 +534,7 @@ function contiguous(ids, start) {
|
|
|
530
534
|
}
|
|
531
535
|
return true;
|
|
532
536
|
}
|
|
533
|
-
function maximumObservedId(values, fallback) {
|
|
537
|
+
export function maximumObservedId(values, fallback) {
|
|
534
538
|
let maximum = fallback;
|
|
535
539
|
for (const value of values) {
|
|
536
540
|
if (!Number.isSafeInteger(value)) {
|
|
@@ -743,7 +747,7 @@ export function indexWithProposedChanges(base, changes, replacedRoots = []) {
|
|
|
743
747
|
}
|
|
744
748
|
return MapWorkspaceIndex.build(files, base.sourceRoots, base.sharedIndex.rebuild(files));
|
|
745
749
|
}
|
|
746
|
-
function addChange(changes, relativePath, content, operationId, mediaType) {
|
|
750
|
+
export function addChange(changes, relativePath, content, operationId, mediaType) {
|
|
747
751
|
const key = normalizeRelative(relativePath);
|
|
748
752
|
const current = changes.get(key);
|
|
749
753
|
if (current === undefined) {
|
|
@@ -772,9 +776,10 @@ function proposedChanges(changes) {
|
|
|
772
776
|
}
|
|
773
777
|
/** Source folders a new world replaces, per plan, so lower layers stop counting. */
|
|
774
778
|
/** Diagnostics an operation raises while applying, drained into the plan after it succeeds. */
|
|
775
|
-
const diagnosticsByPlan = new WeakMap();
|
|
779
|
+
export const diagnosticsByPlan = new WeakMap();
|
|
776
780
|
const replacedRootsByPlan = new WeakMap();
|
|
777
|
-
|
|
781
|
+
const worldOutputsByPlan = new WeakMap();
|
|
782
|
+
export function currentIndex(base, changes) {
|
|
778
783
|
return indexWithProposedChanges(base, proposedChanges(changes), replacedRootsByPlan.get(changes) ?? []);
|
|
779
784
|
}
|
|
780
785
|
function samePolicy(actual, expected) {
|
|
@@ -854,7 +859,7 @@ function requireSplitPolicy(operation) {
|
|
|
854
859
|
}
|
|
855
860
|
return policy;
|
|
856
861
|
}
|
|
857
|
-
function requireSafeLocalisationDocument(document, relativePath) {
|
|
862
|
+
export function requireSafeLocalisationDocument(document, relativePath) {
|
|
858
863
|
if (document.encoding !== 'utf8-bom') {
|
|
859
864
|
throw new ServiceError('MAP_STATE_LOCALISATION_ENCODING_INVALID', 'State localisation edits require an existing UTF-8 BOM source file', { file: relativePath, encoding: document.encoding });
|
|
860
865
|
}
|
|
@@ -863,7 +868,7 @@ function requireSafeLocalisationDocument(document, relativePath) {
|
|
|
863
868
|
throw new ServiceError('MAP_STATE_LOCALISATION_SOURCE_INVALID', 'State localisation cannot target a malformed source file', { file: relativePath, diagnostics: errors.map(({ code }) => code) });
|
|
864
869
|
}
|
|
865
870
|
}
|
|
866
|
-
function safeLocalisationRelativePath(index, requested) {
|
|
871
|
+
export function safeLocalisationRelativePath(index, requested) {
|
|
867
872
|
const value = requested.replaceAll('\\', '/');
|
|
868
873
|
const segments = value.split('/');
|
|
869
874
|
if (value === '' ||
|
|
@@ -881,7 +886,7 @@ function safeLocalisationRelativePath(index, requested) {
|
|
|
881
886
|
}
|
|
882
887
|
return value;
|
|
883
888
|
}
|
|
884
|
-
function escapedLocalisationValue(value) {
|
|
889
|
+
export function escapedLocalisationValue(value) {
|
|
885
890
|
return value
|
|
886
891
|
.replaceAll('\\', '\\\\')
|
|
887
892
|
.replaceAll('"', '\\"')
|
|
@@ -889,7 +894,7 @@ function escapedLocalisationValue(value) {
|
|
|
889
894
|
.replaceAll('\r', '\\n')
|
|
890
895
|
.replaceAll('\n', '\\n');
|
|
891
896
|
}
|
|
892
|
-
function localisationEntryLine(document, entry, value) {
|
|
897
|
+
export function localisationEntryLine(document, entry, value) {
|
|
893
898
|
const line = document.text.slice(entry.start, entry.end);
|
|
894
899
|
const opening = line.indexOf('"');
|
|
895
900
|
if (opening < 0)
|
|
@@ -915,7 +920,7 @@ function localisationEntryLine(document, entry, value) {
|
|
|
915
920
|
throw new ServiceError('MAP_STATE_LOCALISATION_SOURCE_INVALID', 'State localisation source entry has an unclosed quoted value', { file: document.path, line: entry.line });
|
|
916
921
|
return `${line.slice(0, opening + 1)}${escapedLocalisationValue(value)}${line.slice(closing)}`;
|
|
917
922
|
}
|
|
918
|
-
function appendLocalisationEntry(document, key, value) {
|
|
923
|
+
export function appendLocalisationEntry(document, key, value) {
|
|
919
924
|
let englishHeaders = 0;
|
|
920
925
|
const matcher = /^\s*l_english:\s*(?:#.*)?$/gmu;
|
|
921
926
|
while (matcher.exec(document.text) !== null && englishHeaders < 2)
|
|
@@ -1008,7 +1013,7 @@ function splitScalar(total, ratio, policy) {
|
|
|
1008
1013
|
* count of slot buildings. A level applies to the whole state, so a split keeps it in both parts
|
|
1009
1014
|
* and a merge keeps the highest; slot buildings are divided and summed.
|
|
1010
1015
|
*/
|
|
1011
|
-
function isLevelStateBuilding(index, building) {
|
|
1016
|
+
export function isLevelStateBuilding(index, building) {
|
|
1012
1017
|
const definition = index.buildingDefinitions.get(building);
|
|
1013
1018
|
if (definition !== undefined)
|
|
1014
1019
|
return !definition.sharesSlots && !definition.provincial;
|
|
@@ -1076,7 +1081,7 @@ function mergeBounds(left, right) {
|
|
|
1076
1081
|
count: left.count + right.count,
|
|
1077
1082
|
};
|
|
1078
1083
|
}
|
|
1079
|
-
function regionMembershipId(index, provinceId) {
|
|
1084
|
+
export function regionMembershipId(index, provinceId) {
|
|
1080
1085
|
const regions = index.regionForProvince(provinceId);
|
|
1081
1086
|
if (regions.length !== 1)
|
|
1082
1087
|
throw new ServiceError('MAP_REGION_MEMBERSHIP_AMBIGUOUS', `Province ${provinceId} must have exactly one strategic region`);
|
|
@@ -1088,7 +1093,7 @@ function stateMembershipId(index, provinceId) {
|
|
|
1088
1093
|
throw new ServiceError('MAP_STATE_MEMBERSHIP_AMBIGUOUS', `Province ${provinceId} belongs to multiple states`);
|
|
1089
1094
|
return states[0]?.id;
|
|
1090
1095
|
}
|
|
1091
|
-
function commitState(changes, state, data, fields, operationId, datedRewrite, datedRewriteFollowsProvinces = datedRewrite !== undefined) {
|
|
1096
|
+
export function commitState(changes, state, data, fields, operationId, datedRewrite, datedRewriteFollowsProvinces = datedRewrite !== undefined) {
|
|
1092
1097
|
// Distributed dated history already follows the new province membership.
|
|
1093
1098
|
assertDatedStateHistorySafe(state, datedRewriteFollowsProvinces
|
|
1094
1099
|
? { ...fields, provinces: false, victoryPoints: false, buildings: false }
|
|
@@ -1181,7 +1186,7 @@ function distributeDatedHistory(index, state, moved, ratio) {
|
|
|
1181
1186
|
}
|
|
1182
1187
|
return result;
|
|
1183
1188
|
}
|
|
1184
|
-
function assertDatedStateHistorySafe(state, fields, operationId) {
|
|
1189
|
+
export function assertDatedStateHistorySafe(state, fields, operationId) {
|
|
1185
1190
|
if (state.historyBlock === undefined)
|
|
1186
1191
|
return;
|
|
1187
1192
|
for (const entry of assignments(state.historyBlock)) {
|
|
@@ -1269,14 +1274,13 @@ function exactNumericMap(values, label) {
|
|
|
1269
1274
|
}
|
|
1270
1275
|
return result;
|
|
1271
1276
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const state = index.statesById.get(
|
|
1277
|
+
/** Apply one state update against an index that already holds every earlier change. */
|
|
1278
|
+
export function updateStateAgainst(index, changes, stateId, values, operationId) {
|
|
1279
|
+
const state = index.statesById.get(stateId);
|
|
1275
1280
|
if (state === undefined)
|
|
1276
|
-
throw new ServiceError('MAP_STATE_NOT_FOUND', `State ${
|
|
1281
|
+
throw new ServiceError('MAP_STATE_NOT_FOUND', `State ${stateId} does not exist`);
|
|
1277
1282
|
const desired = cloneState(state);
|
|
1278
1283
|
const fields = {};
|
|
1279
|
-
const values = operation.changes;
|
|
1280
1284
|
if (values.capital !== undefined) {
|
|
1281
1285
|
if (values.victoryPoints === undefined)
|
|
1282
1286
|
throw new ServiceError('MAP_STATE_CAPITAL_ASSERTION_REQUIRES_VICTORY_POINTS', 'A derived state-capital assertion requires an exact victoryPoints payload');
|
|
@@ -1349,7 +1353,25 @@ function applyUpdateState(base, changes, operation) {
|
|
|
1349
1353
|
desired.provinceBuildings = provinceBuildings;
|
|
1350
1354
|
fields.buildings = true;
|
|
1351
1355
|
}
|
|
1352
|
-
commitState(changes, state, desired, fields,
|
|
1356
|
+
commitState(changes, state, desired, fields, operationId);
|
|
1357
|
+
}
|
|
1358
|
+
function applyUpdateState(base, changes, operation) {
|
|
1359
|
+
updateStateAgainst(currentIndex(base, changes), changes, operation.stateId, operation.changes, operation.id);
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Update many states in one operation: the index is built once and each state is patched in
|
|
1363
|
+
* place, so a politics pass over thousands of states costs one validation. All or nothing.
|
|
1364
|
+
*/
|
|
1365
|
+
function applyUpdateStates(base, changes, operation) {
|
|
1366
|
+
const seen = new Set();
|
|
1367
|
+
for (const { stateId } of operation.updates) {
|
|
1368
|
+
if (seen.has(stateId))
|
|
1369
|
+
throw new ServiceError('MAP_UPDATE_STATES_DUPLICATE', `State ${stateId} appears twice in updates; merge its changes into one entry`, { stateId });
|
|
1370
|
+
seen.add(stateId);
|
|
1371
|
+
}
|
|
1372
|
+
const index = currentIndex(base, changes);
|
|
1373
|
+
for (const { stateId, changes: values } of operation.updates)
|
|
1374
|
+
updateStateAgainst(index, changes, stateId, values, operation.id);
|
|
1353
1375
|
}
|
|
1354
1376
|
function applyMoveStateProvinces(base, changes, operation) {
|
|
1355
1377
|
const policy = requireMovePolicy(operation);
|
|
@@ -1558,7 +1580,7 @@ function requireMergeStatePolicy(operation) {
|
|
|
1558
1580
|
throw new ServiceError('MAP_STATE_DISTRIBUTION_REQUIRED', 'State merge requires a complete explicit value, ownership, port, network, position, and province-data distribution policy');
|
|
1559
1581
|
}
|
|
1560
1582
|
}
|
|
1561
|
-
function removalContent(index, file, label) {
|
|
1583
|
+
export function removalContent(index, file, label) {
|
|
1562
1584
|
const lowerExists = index.sourceFiles.some((candidate) => normalizeRelative(candidate.relativePath) === normalizeRelative(file.relativePath) &&
|
|
1563
1585
|
candidate.displayPath !== file.displayPath &&
|
|
1564
1586
|
candidate.loadOrder < file.loadOrder);
|
|
@@ -2784,7 +2806,7 @@ function handleSimpleOperation(base, changes, operation) {
|
|
|
2784
2806
|
addChange(changes, file.relativePath, content, operation.id, 'text/plain');
|
|
2785
2807
|
}
|
|
2786
2808
|
}
|
|
2787
|
-
function buildingPositionLine(value) {
|
|
2809
|
+
export function buildingPositionLine(value) {
|
|
2788
2810
|
return [
|
|
2789
2811
|
value.stateId,
|
|
2790
2812
|
value.building,
|
|
@@ -3314,9 +3336,40 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3314
3336
|
throw new ServiceError('MAP_LOOK_CUSTOM_PALETTE_REQUIRED', 'look "custom" needs colormap.palette with at least one class colour');
|
|
3315
3337
|
if (colormapProfile === 'legacy' && look !== undefined)
|
|
3316
3338
|
throw new ServiceError('MAP_LOOK_REQUIRES_CALIBRATED_COLORMAP', 'look selects a calibrated palette; remove colormap.profile "legacy" or look');
|
|
3339
|
+
if (!hasGeography && operation.development?.model === 'geography')
|
|
3340
|
+
throw new ServiceError('MAP_DEVELOPMENT_REQUIRES_GEOGRAPHY', 'development.model "geography" plans the economy from a geography model; give geography or geographyPath, or use "legacy"');
|
|
3341
|
+
if (!hasGeography && operation.autoStraits !== undefined && operation.autoStraits !== false)
|
|
3342
|
+
throw new ServiceError('MAP_AUTO_STRAITS_REQUIRES_GEOGRAPHY', 'autoStraits detects channels between the landmasses of a geography world; give geography or geographyPath, or remove autoStraits');
|
|
3343
|
+
const autoStraits = operation.autoStraits === undefined || operation.autoStraits === false
|
|
3344
|
+
? undefined
|
|
3345
|
+
: {
|
|
3346
|
+
maxGapPx: operation.autoStraits === true
|
|
3347
|
+
? DEFAULT_AUTO_STRAIT_GAP_PX
|
|
3348
|
+
: (operation.autoStraits.maxGapPx ?? DEFAULT_AUTO_STRAIT_GAP_PX),
|
|
3349
|
+
};
|
|
3350
|
+
const workspaceCountries = operation.countries !== undefined && !Array.isArray(operation.countries)
|
|
3351
|
+
? operation.countries
|
|
3352
|
+
: undefined;
|
|
3353
|
+
const tagCatalog = countryTagCatalog(base);
|
|
3354
|
+
if (workspaceCountries !== undefined) {
|
|
3355
|
+
const declared = new Set(tagCatalog.static);
|
|
3356
|
+
const dynamicTags = new Set(tagCatalog.dynamic);
|
|
3357
|
+
for (const realm of workspaceCountries.realms) {
|
|
3358
|
+
if (dynamicTags.has(realm.tag))
|
|
3359
|
+
throw new ServiceError('MAP_WORLD_REALM_TAG_DYNAMIC', `${realm.tag} is a dynamic tag; a world can only place countries with static tags`, { tag: realm.tag });
|
|
3360
|
+
if (!declared.has(realm.tag))
|
|
3361
|
+
throw new ServiceError('MAP_WORLD_REALM_TAG_UNKNOWN', `No active common/country_tags file other than the generated world's declares ${realm.tag}`, { tag: realm.tag });
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3317
3364
|
const partitionMode = resolvePartitionMode(operation.partition, hasGeography);
|
|
3318
3365
|
if (partitionMode === 'legacy' && operation.partitionTuning !== undefined)
|
|
3319
3366
|
throw new ServiceError('MAP_PARTITION_TUNING_REQUIRES_FEATURE_AWARE', 'partitionTuning applies to the feature-aware partition; set partition to "feature-aware" or remove it');
|
|
3367
|
+
if (operation.synthesisCheck === true && context.synthesisCheck !== true)
|
|
3368
|
+
throw new ServiceError('MAP_SYNTHESIS_CHECK_REQUIRES_PREVIEW', "synthesisCheck checks the model's rivers without writing; call hoi4.map_rewrite with preview: true, or remove synthesisCheck to write the world");
|
|
3369
|
+
if (operation.synthesisCheck === true && !hasGeography)
|
|
3370
|
+
throw new ServiceError('MAP_SYNTHESIS_CHECK_REQUIRES_GEOGRAPHY', 'synthesisCheck runs the relief and river synthesis of a geography model; give geography or geographyPath');
|
|
3371
|
+
if (partitionMode === 'legacy' && operation.borderQuality === 'enforce')
|
|
3372
|
+
throw new ServiceError('MAP_BORDER_QUALITY_ENFORCE_NEEDS_FEATURE_AWARE', 'borderQuality "enforce" needs the feature-aware partition: the legacy partition cuts at chance level against rivers and slopes and would block every write. Set partition to "feature-aware", or use "report"');
|
|
3320
3373
|
const world = generateWorld({
|
|
3321
3374
|
startingScenario,
|
|
3322
3375
|
...(operation.worldName === undefined ? {} : { worldName: operation.worldName }),
|
|
@@ -3332,13 +3385,29 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3332
3385
|
provincesPerState: operation.provincesPerState ?? (reference === undefined ? 5 : 12),
|
|
3333
3386
|
landProvincesPerRegion: operation.landProvincesPerRegion ?? (reference === undefined ? 25 : 60),
|
|
3334
3387
|
seaProvincesPerRegion: operation.seaProvincesPerRegion ?? (reference === undefined ? 10 : 30),
|
|
3335
|
-
countries:
|
|
3388
|
+
countries: workspaceCountries?.realms.map((realm) => ({
|
|
3389
|
+
tag: realm.tag,
|
|
3390
|
+
name: realm.tag,
|
|
3391
|
+
...(realm.weight === undefined ? {} : { share: realm.weight }),
|
|
3392
|
+
})) ??
|
|
3393
|
+
operation.countries ??
|
|
3336
3394
|
reference?.countries ?? [
|
|
3337
3395
|
{ tag: 'X01', name: 'Aster' },
|
|
3338
3396
|
{ tag: 'X02', name: 'Brum' },
|
|
3339
3397
|
{ tag: 'X03', name: 'Corvel' },
|
|
3340
3398
|
{ tag: 'X04', name: 'Doran' },
|
|
3341
3399
|
],
|
|
3400
|
+
...(workspaceCountries === undefined
|
|
3401
|
+
? {}
|
|
3402
|
+
: {
|
|
3403
|
+
realms: {
|
|
3404
|
+
list: workspaceCountries.realms,
|
|
3405
|
+
...(workspaceCountries.unowned === undefined
|
|
3406
|
+
? {}
|
|
3407
|
+
: { unowned: workspaceCountries.unowned }),
|
|
3408
|
+
islands: workspaceCountries.islands ?? 'nearest',
|
|
3409
|
+
},
|
|
3410
|
+
}),
|
|
3342
3411
|
...(reference === undefined
|
|
3343
3412
|
? {}
|
|
3344
3413
|
: {
|
|
@@ -3375,6 +3444,10 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3375
3444
|
}),
|
|
3376
3445
|
...(edge.mode === 'none' ? {} : { edge }),
|
|
3377
3446
|
...(operation.ambientFrame === true ? { ambientFrame: true } : {}),
|
|
3447
|
+
...(autoStraits === undefined ? {} : { autoStraits }),
|
|
3448
|
+
...(operation.development?.model === undefined
|
|
3449
|
+
? {}
|
|
3450
|
+
: { developmentModel: operation.development.model }),
|
|
3378
3451
|
colormapProfile,
|
|
3379
3452
|
...(partitionMode === 'legacy'
|
|
3380
3453
|
? {}
|
|
@@ -3390,7 +3463,25 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3390
3463
|
...(operation.colormap?.palette === undefined
|
|
3391
3464
|
? {}
|
|
3392
3465
|
: { colormapPalette: colormapPaletteOverrides(operation.colormap.palette) }),
|
|
3393
|
-
}, buildWorldContext(base), { signal });
|
|
3466
|
+
}, buildWorldContext(base), { signal, ...(operation.synthesisCheck === true ? { riverCheck: true } : {}) });
|
|
3467
|
+
if (operation.synthesisCheck === true)
|
|
3468
|
+
return [
|
|
3469
|
+
...world.notes.map((note) => ({
|
|
3470
|
+
code: note.code,
|
|
3471
|
+
severity: note.severity,
|
|
3472
|
+
category: 'map',
|
|
3473
|
+
operationId: operation.id,
|
|
3474
|
+
message: note.message,
|
|
3475
|
+
...(note.details === undefined ? {} : { details: note.details }),
|
|
3476
|
+
})),
|
|
3477
|
+
{
|
|
3478
|
+
code: 'MAP_SYNTHESIS_CHECK_READY',
|
|
3479
|
+
severity: 'info',
|
|
3480
|
+
category: 'map',
|
|
3481
|
+
operationId: operation.id,
|
|
3482
|
+
message: `Every river of the model reaches its mouth over the synthesised relief of a ${world.summary.width}x${world.summary.height} map; nothing was written`,
|
|
3483
|
+
},
|
|
3484
|
+
];
|
|
3394
3485
|
const roots = operation.replaceBaseGame === false
|
|
3395
3486
|
? []
|
|
3396
3487
|
: [...WORLD_REPLACED_ROOTS, ...(startingScenario ? STARTING_SCENARIO_REPLACED_ROOTS : [])];
|
|
@@ -3398,7 +3489,7 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3398
3489
|
for (const file of world.files)
|
|
3399
3490
|
addChange(changes, file.relativePath, file.content, operation.id, file.mediaType);
|
|
3400
3491
|
// Remove only unchanged outputs recorded by a previous generation, before indexing new IDs.
|
|
3401
|
-
for (const file of
|
|
3492
|
+
for (const file of planWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation, { releaseCountryOutputs: workspaceCountries !== undefined }).changes)
|
|
3402
3493
|
if (file.content === null)
|
|
3403
3494
|
addChange(changes, file.relativePath, null, operation.id, 'text/plain');
|
|
3404
3495
|
// Start from empty derived files so no base-game position or supply line survives.
|
|
@@ -3416,10 +3507,21 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3416
3507
|
for (const file of planMapPositions(generated, { mode: 'replace' }))
|
|
3417
3508
|
addChange(changes, file.relativePath, file.content, operation.id, 'text/plain');
|
|
3418
3509
|
const positioned = currentIndex(base, changes);
|
|
3419
|
-
for (const file of planSupplyRebuild(positioned, {
|
|
3510
|
+
for (const file of planSupplyRebuild(positioned, {
|
|
3511
|
+
mode: 'replace',
|
|
3512
|
+
...(world.railCost === undefined ? {} : { railCost: world.railCost }),
|
|
3513
|
+
}))
|
|
3420
3514
|
addChange(changes, file.relativePath, file.content, operation.id, 'text/plain');
|
|
3421
|
-
|
|
3515
|
+
const outputs = planWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation, { releaseCountryOutputs: workspaceCountries !== undefined });
|
|
3516
|
+
for (const file of outputs.changes)
|
|
3422
3517
|
addChange(changes, file.relativePath, file.content, operation.id, file.mediaType ?? 'text/plain');
|
|
3518
|
+
worldOutputsByPlan.set(changes, {
|
|
3519
|
+
operationId: operation.id,
|
|
3520
|
+
written: outputs.written,
|
|
3521
|
+
retired: outputs.retired,
|
|
3522
|
+
released: outputs.released,
|
|
3523
|
+
...(world.realms === undefined ? {} : { realms: world.realms }),
|
|
3524
|
+
});
|
|
3423
3525
|
return [
|
|
3424
3526
|
...(reference?.diagnostics ?? []).map((message) => ({
|
|
3425
3527
|
code: 'MAP_REFERENCE_INTERPRETATION',
|
|
@@ -3448,6 +3550,24 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3448
3550
|
details: { warningCount: world.warnings.length, samples: world.warnings.slice(0, 5) },
|
|
3449
3551
|
},
|
|
3450
3552
|
]),
|
|
3553
|
+
worldOutputsDiagnostic(operation.id, outputs),
|
|
3554
|
+
...worldTagDiagnostics(operation.id, tagCatalog, world, workspaceCountries !== undefined),
|
|
3555
|
+
...worldRealmDiagnostics(operation.id, world),
|
|
3556
|
+
...(outputs.released.length === 0
|
|
3557
|
+
? []
|
|
3558
|
+
: [
|
|
3559
|
+
{
|
|
3560
|
+
code: 'MAP_WORLD_OUTPUT_RELEASED',
|
|
3561
|
+
severity: 'info',
|
|
3562
|
+
category: 'map',
|
|
3563
|
+
operationId: operation.id,
|
|
3564
|
+
message: `${outputs.released.length} edited country file(s) of the previous world are left to the workspace and no longer recorded as generated`,
|
|
3565
|
+
details: {
|
|
3566
|
+
count: outputs.released.length,
|
|
3567
|
+
files: outputs.released.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
|
|
3568
|
+
},
|
|
3569
|
+
},
|
|
3570
|
+
]),
|
|
3451
3571
|
{
|
|
3452
3572
|
code: 'MAP_WORLD_CREATED',
|
|
3453
3573
|
severity: 'info',
|
|
@@ -3470,6 +3590,107 @@ function applyCreateWorld(base, changes, operation, context, signal) {
|
|
|
3470
3590
|
]),
|
|
3471
3591
|
];
|
|
3472
3592
|
}
|
|
3593
|
+
/** Tag count observed to overflow the flag sheet of one 1.19 Windows build; never a block. */
|
|
3594
|
+
export const WORLD_TAG_COUNT_OBSERVED_LIMIT = 439;
|
|
3595
|
+
function worldTagDiagnostics(operationId, catalog, world, workspaceOwned) {
|
|
3596
|
+
const declared = new Set(catalog.static);
|
|
3597
|
+
const generated = workspaceOwned
|
|
3598
|
+
? 0
|
|
3599
|
+
: worldGeneratedTags(world).filter((tag) => !declared.has(tag)).length;
|
|
3600
|
+
const total = declared.size + generated + catalog.dynamic.length;
|
|
3601
|
+
const details = {
|
|
3602
|
+
static: declared.size + generated,
|
|
3603
|
+
generated,
|
|
3604
|
+
dynamic: catalog.dynamic.length,
|
|
3605
|
+
total,
|
|
3606
|
+
observedLimit: WORLD_TAG_COUNT_OBSERVED_LIMIT,
|
|
3607
|
+
};
|
|
3608
|
+
return [
|
|
3609
|
+
{
|
|
3610
|
+
code: 'MAP_WORLD_TAG_COUNT',
|
|
3611
|
+
severity: 'info',
|
|
3612
|
+
category: 'map',
|
|
3613
|
+
operationId,
|
|
3614
|
+
message: `After this write ${total} country tags are declared: ${details.static} static (${generated} written by the world) and ${details.dynamic} dynamic`,
|
|
3615
|
+
details,
|
|
3616
|
+
},
|
|
3617
|
+
...(total <= WORLD_TAG_COUNT_OBSERVED_LIMIT
|
|
3618
|
+
? []
|
|
3619
|
+
: [
|
|
3620
|
+
{
|
|
3621
|
+
code: 'MAP_WORLD_TAG_COUNT_HIGH',
|
|
3622
|
+
severity: 'warning',
|
|
3623
|
+
category: 'map',
|
|
3624
|
+
operationId,
|
|
3625
|
+
message: `${total} country tags exceed ${WORLD_TAG_COUNT_OBSERVED_LIMIT}, the count at which one measured game build overflowed its flag sheet`,
|
|
3626
|
+
details,
|
|
3627
|
+
},
|
|
3628
|
+
]),
|
|
3629
|
+
];
|
|
3630
|
+
}
|
|
3631
|
+
/** Tags of the country files a generated world writes. */
|
|
3632
|
+
function worldGeneratedTags(world) {
|
|
3633
|
+
const file = world.files.find(({ relativePath }) => relativePath === WORLD_COUNTRY_TAG_FILE);
|
|
3634
|
+
if (file === undefined)
|
|
3635
|
+
return [];
|
|
3636
|
+
return [
|
|
3637
|
+
...new Set([...file.content.toString('utf8').matchAll(/^([A-Z][A-Z0-9]{2}) =/gmu)].map((match) => match[1])),
|
|
3638
|
+
];
|
|
3639
|
+
}
|
|
3640
|
+
function worldRealmDiagnostics(operationId, world) {
|
|
3641
|
+
if (world.realms === undefined)
|
|
3642
|
+
return [];
|
|
3643
|
+
const { countries, unownedStates } = world.realms;
|
|
3644
|
+
return [
|
|
3645
|
+
{
|
|
3646
|
+
code: 'MAP_WORLD_REALMS_PLACED',
|
|
3647
|
+
severity: 'info',
|
|
3648
|
+
category: 'map',
|
|
3649
|
+
operationId,
|
|
3650
|
+
message: `Placed ${countries.length} workspace countries; their country files, histories, flags and names stay the workspace's`,
|
|
3651
|
+
details: {
|
|
3652
|
+
countries: countries
|
|
3653
|
+
.slice(0, 100)
|
|
3654
|
+
.map(({ tag, capitalState, states, landShare, targetShare }) => ({
|
|
3655
|
+
tag,
|
|
3656
|
+
capitalState,
|
|
3657
|
+
states: states.length,
|
|
3658
|
+
landShare,
|
|
3659
|
+
targetShare,
|
|
3660
|
+
})),
|
|
3661
|
+
truncated: countries.length > 100,
|
|
3662
|
+
},
|
|
3663
|
+
},
|
|
3664
|
+
...(unownedStates.length === 0
|
|
3665
|
+
? []
|
|
3666
|
+
: [
|
|
3667
|
+
{
|
|
3668
|
+
code: 'MAP_WORLD_STATE_UNOWNED',
|
|
3669
|
+
severity: 'info',
|
|
3670
|
+
category: 'map',
|
|
3671
|
+
operationId,
|
|
3672
|
+
message: `${unownedStates.length} state(s) have no owner, as the request asks; whether the game build accepts unowned land is for the mod to verify`,
|
|
3673
|
+
details: { count: unownedStates.length, states: unownedStates.slice(0, 200) },
|
|
3674
|
+
},
|
|
3675
|
+
]),
|
|
3676
|
+
];
|
|
3677
|
+
}
|
|
3678
|
+
function worldOutputsDiagnostic(operationId, outputs) {
|
|
3679
|
+
return {
|
|
3680
|
+
code: 'MAP_WORLD_OUTPUTS_RECONCILED',
|
|
3681
|
+
severity: 'info',
|
|
3682
|
+
category: 'map',
|
|
3683
|
+
operationId,
|
|
3684
|
+
message: `The world records ${outputs.written.length} generated file(s), retires ${outputs.retired.length} earlier output(s) it no longer writes and releases ${outputs.released.length} to the workspace`,
|
|
3685
|
+
details: {
|
|
3686
|
+
written: outputs.written.length,
|
|
3687
|
+
retired: outputs.retired.length,
|
|
3688
|
+
released: outputs.released.length,
|
|
3689
|
+
retiredFiles: outputs.retired.slice(0, WORLD_OUTPUT_PATH_SAMPLE),
|
|
3690
|
+
truncated: outputs.retired.length > WORLD_OUTPUT_PATH_SAMPLE,
|
|
3691
|
+
},
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3473
3694
|
function applyRegenerateMapPositions(base, changes, operation) {
|
|
3474
3695
|
const index = currentIndex(base, changes);
|
|
3475
3696
|
for (const file of planMapPositions(index, operation))
|
|
@@ -3484,7 +3705,7 @@ function applyRebuildSupply(base, changes, operation) {
|
|
|
3484
3705
|
* Rewrite the script references of one entity kind in the proposed changes. Returns the number
|
|
3485
3706
|
* of script files changed; an empty result is an error only when the caller asks for it.
|
|
3486
3707
|
*/
|
|
3487
|
-
function remapScriptReferences(base, changes, operationId, request) {
|
|
3708
|
+
export function remapScriptReferences(base, changes, operationId, request) {
|
|
3488
3709
|
const index = currentIndex(base, changes);
|
|
3489
3710
|
let plan;
|
|
3490
3711
|
try {
|
|
@@ -3567,6 +3788,8 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
|
|
|
3567
3788
|
const allocations = [];
|
|
3568
3789
|
let expectedChangedBounds;
|
|
3569
3790
|
const manifestOperations = [];
|
|
3791
|
+
const territory = [];
|
|
3792
|
+
const worldOutputs = [];
|
|
3570
3793
|
const ids = new Set();
|
|
3571
3794
|
for (const [operationIndex, operation] of operations.entries()) {
|
|
3572
3795
|
yield* cancellationCheckpoint(signal, operationIndex);
|
|
@@ -3595,13 +3818,31 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
|
|
|
3595
3818
|
]));
|
|
3596
3819
|
const allocationsCheckpoint = allocations.length;
|
|
3597
3820
|
const boundsCheckpoint = expectedChangedBounds;
|
|
3821
|
+
const territoryCheckpoint = territory.length;
|
|
3598
3822
|
try {
|
|
3599
|
-
if (operation.kind === 'create_world')
|
|
3823
|
+
if (operation.kind === 'create_world') {
|
|
3600
3824
|
diagnostics.push(...applyCreateWorld(base, changes, operation, context, signal));
|
|
3825
|
+
const outputs = worldOutputsByPlan.get(changes);
|
|
3826
|
+
worldOutputsByPlan.delete(changes);
|
|
3827
|
+
if (outputs !== undefined)
|
|
3828
|
+
worldOutputs.push(outputs);
|
|
3829
|
+
}
|
|
3601
3830
|
else if (operation.kind === 'move_state_provinces')
|
|
3602
3831
|
applyMoveStateProvinces(base, changes, operation);
|
|
3832
|
+
else if (operation.kind === 'assign_territory') {
|
|
3833
|
+
const outcome = yield* applyAssignTerritory(base, changes, allocations, operation, signal);
|
|
3834
|
+
diagnostics.push(...outcome.diagnostics);
|
|
3835
|
+
territory.push(outcome.report);
|
|
3836
|
+
}
|
|
3837
|
+
else if (operation.kind === 'repartition_states') {
|
|
3838
|
+
const outcome = yield* applyRepartitionStates(base, changes, allocations, operation, signal);
|
|
3839
|
+
diagnostics.push(...outcome.diagnostics);
|
|
3840
|
+
territory.push(outcome.report);
|
|
3841
|
+
}
|
|
3603
3842
|
else if (operation.kind === 'update_state')
|
|
3604
3843
|
applyUpdateState(base, changes, operation);
|
|
3844
|
+
else if (operation.kind === 'update_states')
|
|
3845
|
+
applyUpdateStates(base, changes, operation);
|
|
3605
3846
|
else if (operation.kind === 'split_state' || operation.kind === 'create_state')
|
|
3606
3847
|
yield* applySplitState(base, changes, allocations, operation, signal);
|
|
3607
3848
|
else if (operation.kind === 'merge_states')
|
|
@@ -3641,6 +3882,7 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
|
|
|
3641
3882
|
changes.set(key, change);
|
|
3642
3883
|
allocations.length = allocationsCheckpoint;
|
|
3643
3884
|
expectedChangedBounds = boundsCheckpoint;
|
|
3885
|
+
territory.length = territoryCheckpoint;
|
|
3644
3886
|
const serviceError = error instanceof ServiceError
|
|
3645
3887
|
? error
|
|
3646
3888
|
: new ServiceError('MAP_OPERATION_FAILED', error instanceof Error ? error.message : String(error));
|
|
@@ -3674,6 +3916,8 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
|
|
|
3674
3916
|
...(expectedChangedBounds === undefined ? {} : { expectedChangedBounds }),
|
|
3675
3917
|
finalIndex,
|
|
3676
3918
|
operations: manifestOperations,
|
|
3919
|
+
...(territory.length === 0 ? {} : { territory }),
|
|
3920
|
+
...(worldOutputs.length === 0 ? {} : { worldOutputs }),
|
|
3677
3921
|
};
|
|
3678
3922
|
}
|
|
3679
3923
|
export function planMapOperations(base, operations, signal, context = {}) {
|
|
@@ -248,8 +248,11 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
|
|
|
248
248
|
return id === 0 ? undefined : id;
|
|
249
249
|
};
|
|
250
250
|
let countries;
|
|
251
|
-
if (operation.countries
|
|
251
|
+
if (Array.isArray(operation.countries))
|
|
252
252
|
countries = operation.countries;
|
|
253
|
+
// Workspace-owned countries are placed from their own points; the picture's colours are unused.
|
|
254
|
+
else if (operation.countries !== undefined)
|
|
255
|
+
countries = [];
|
|
253
256
|
else if (surface.inferredCountries.length > 1) {
|
|
254
257
|
countries = surface.inferredCountries.slice(0, 200).map((country, index) => ({
|
|
255
258
|
tag: `X${(index + 1).toString(36).toUpperCase().padStart(2, '0')}`,
|