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.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  3. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  4. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  5. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  6. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  7. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  8. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  9. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  10. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  16. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  17. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  18. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  19. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  20. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  21. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  22. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  23. package/dist/hoi4_agent_tools/map/model.js +2 -1
  24. package/dist/hoi4_agent_tools/map/operations.js +282 -38
  25. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  26. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  27. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
  28. package/dist/hoi4_agent_tools/map/service.js +93 -16
  29. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  30. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  31. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  32. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  33. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  34. package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
  35. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  36. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  37. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  38. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  39. package/dist/hoi4_agent_tools/map/world.js +400 -145
  40. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  41. package/dist/hoi4_agent_tools/schemas/map.js +277 -34
  42. package/dist/hoi4_agent_tools/version.js +1 -1
  43. package/docs/focus.md +6 -0
  44. package/docs/map.md +132 -4
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +1 -1
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/geography-model.schema.json +13 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +1541 -786
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/reference-sheets.schema.json +1 -1
  81. package/schemas/scenario-suite.schema.json +1 -1
  82. package/schemas/scenario-test-input.schema.json +1 -1
  83. package/schemas/script-validate-input.schema.json +1 -1
  84. package/schemas/shared-scenario.schema.json +1 -1
  85. package/schemas/source-lookup-input.schema.json +1 -1
  86. 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 { reconcileWorldOutputs } from './world-outputs.js';
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
- function normalizeRelative(value) {
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
- function currentIndex(base, changes) {
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
- function applyUpdateState(base, changes, operation) {
1273
- const index = currentIndex(base, changes);
1274
- const state = index.statesById.get(operation.stateId);
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 ${operation.stateId} does not exist`);
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, operation.id);
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: operation.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 reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
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, { mode: 'replace' }))
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
- for (const file of reconcileWorldOutputs(base.sourceFiles, proposedChanges(changes), operation.id, mapRoot, base.sourceRoots.states, base.sourceRoots.localisation))
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 !== undefined)
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')}`,