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
@@ -12,13 +12,20 @@ import { generateWorldProvinces } from './world-provinces.js';
12
12
  import { clusterRegions, clusterStates, partitionWorldFromRequest, } from './partition.js';
13
13
  import { repairSeamCorners, smoothDiagonalContacts } from './partition-fields.js';
14
14
  import { PARTITION_PROVINCE_ERROR, PARTITION_PROVINCE_WARNING } from './partition-tuning.js';
15
+ import { buildTerritoryAtoms, territoryGraphFromPartition } from './territory-graph.js';
16
+ import { growTerritory } from './territory.js';
15
17
  import { geographyPartitionFields, provinceCountNote, surfacePartitionFields, } from './world-partition.js';
18
+ import { adjacencyRowText, adjacencyRulesText } from './gameplay/crossings.js';
19
+ import { convertInlandSeas } from './gameplay/inland-sea.js';
20
+ import { planEconomy } from './gameplay/economy.js';
21
+ import { prepareGameplay } from './gameplay/prepare.js';
16
22
  import { fractal, lattice } from './world-noise.js';
17
23
  import { calibratedColourMap, COLOUR_CLASSES, } from './colormap.js';
18
24
  import { ambientObjectText, applyEdgePolicy, assertFeaturesOutsideBand, edgeWaterDarkness, RIM_COLOUR_FACTOR, rimAdjacencyRows, rimHeight, rimStrength, } from './world-edge.js';
19
25
  import { landDistanceField, lakeHeight, normalizeLakes, waterColourLevel } from './water.js';
20
26
  import { overlayGeographyLakes, rasterizeGeographyKind } from './relief/land.js';
21
- import { synthesizeTerrain } from './relief/terrain-synthesis.js';
27
+ import { checkTerrainRivers, synthesizeTerrain, } from './relief/terrain-synthesis.js';
28
+ import { assignUnreachedRealmLand, resolveRealmRules } from './world-realms.js';
22
29
  // ---------------------------------------------------------------------------------------------
23
30
  // Deterministic randomness and noise
24
31
  export class Random {
@@ -506,6 +513,48 @@ export function generateWorld(spec, context, options = {}) {
506
513
  let seamRepaired = geography !== undefined ? repairSeamCorners(surface.kind, width, height) : [];
507
514
  // A geography model gets its relief and rivers from the synthesis of ADR 0056; every other world
508
515
  // keeps the original elevation classes and drainage, byte for byte.
516
+ if (options.riverCheck === true && geography !== undefined) {
517
+ const checked = checkTerrainRivers({
518
+ geography,
519
+ width,
520
+ height,
521
+ seed: spec.seed,
522
+ kind: surface.kind,
523
+ relief: spec.relief,
524
+ rivers: { density: spec.riverDensity, count: spec.riverCount },
525
+ adjustHeight: edge.mode === 'rim'
526
+ ? (x, _y, value) => rimHeight(value, rimStrength(edge, width, x))
527
+ : undefined,
528
+ signal: options.signal,
529
+ });
530
+ for (const note of checked) {
531
+ if (note.severity === 'error')
532
+ throw new ServiceError(note.code, note.message, note.details);
533
+ notes.push({
534
+ code: note.code,
535
+ severity: note.severity,
536
+ message: note.message,
537
+ ...(note.details === undefined ? {} : { details: note.details }),
538
+ });
539
+ }
540
+ return {
541
+ files: [],
542
+ warnings: [],
543
+ notes,
544
+ summary: {
545
+ width,
546
+ height,
547
+ landProvinces: 0,
548
+ seaProvinces: 0,
549
+ lakeProvinces: 0,
550
+ states: 0,
551
+ landRegions: 0,
552
+ seaRegions: 0,
553
+ countries: spec.countries.length,
554
+ rivers: 0,
555
+ },
556
+ };
557
+ }
509
558
  const synthesized = geography === undefined
510
559
  ? undefined
511
560
  : synthesizeTerrain({
@@ -534,6 +583,26 @@ export function generateWorld(spec, context, options = {}) {
534
583
  }
535
584
  notes.push(terrainSummaryNote(synthesized, geography));
536
585
  }
586
+ // A lake the model calls an inland sea becomes navigable water (ADR 0062).
587
+ const inland = geography === undefined || synthesized === undefined
588
+ ? undefined
589
+ : convertInlandSeas(surface.kind, surface.elevation, width, height, geography);
590
+ if (inland !== undefined && geography !== undefined) {
591
+ for (const { lake, pixels } of inland.converted)
592
+ notes.push({
593
+ code: 'MAP_INLAND_SEA_CREATED',
594
+ severity: 'info',
595
+ message: `Lake ${geography.lakes[lake].id} is an inland sea: its ${pixels} water pixels are written as sea provinces so ships and ports can use them`,
596
+ details: { lakeId: geography.lakes[lake].id, pixels },
597
+ });
598
+ for (const lake of inland.missing)
599
+ notes.push({
600
+ code: 'MAP_INLAND_SEA_NO_WATER',
601
+ severity: 'info',
602
+ message: `Lake ${geography.lakes[lake].id} is an inland sea but has no separate water body: it joined the open sea or was too small to keep`,
603
+ details: { lakeId: geography.lakes[lake].id },
604
+ });
605
+ }
537
606
  const drainage = synthesized === undefined
538
607
  ? generateWorldDrainage({
539
608
  width,
@@ -627,6 +696,25 @@ export function generateWorld(spec, context, options = {}) {
627
696
  const countNote = provinceCountNote(count, featurePartition === undefined ? 'legacy' : 'feature-aware');
628
697
  if (countNote !== undefined)
629
698
  notes.push(countNote);
699
+ // Crossings, passes and walls of a geography world (ADR 0062).
700
+ const gameplay = geography === undefined || synthesized === undefined
701
+ ? undefined
702
+ : prepareGameplay({
703
+ width,
704
+ height,
705
+ geography,
706
+ provinces,
707
+ heights: synthesized.heights,
708
+ riverBitmap: drainage.riverPixels,
709
+ fields: synthesized.fields,
710
+ riverPaths: synthesized.riverPaths,
711
+ autoStraits: spec.autoStraits,
712
+ impassableLimit: spec.impassableRowLimit,
713
+ inlandSeas: inland?.converted.map(({ lake }) => lake) ?? [],
714
+ });
715
+ if (gameplay !== undefined)
716
+ for (const note of gameplay.notes)
717
+ notes.push(note);
630
718
  const center = (id) => ({
631
719
  x: provinces.sumX[id] / provinces.size[id],
632
720
  y: provinces.sumY[id] / provinces.size[id],
@@ -763,7 +851,9 @@ export function generateWorld(spec, context, options = {}) {
763
851
  const countryCount = spec.countries.length;
764
852
  const interpretationWarnings = [];
765
853
  const referenceOwners = new Map();
766
- if (reference?.inferredCountries.length === countryCount && countryCount > 1) {
854
+ if (spec.realms === undefined &&
855
+ reference?.inferredCountries.length === countryCount &&
856
+ countryCount > 1) {
767
857
  const indexes = new Map();
768
858
  const unresolved = [];
769
859
  for (const [index, country] of spec.countries.entries()) {
@@ -831,7 +921,20 @@ export function generateWorld(spec, context, options = {}) {
831
921
  }
832
922
  return selected;
833
923
  };
834
- const namedCities = (spec.referenceCities ?? []).map((city) => {
924
+ // Sites of the model that anchor a country (its capital) choose that country's capital province.
925
+ const siteAnchors = geography === undefined
926
+ ? []
927
+ : geography.sites.flatMap((site) => site.country === undefined || site.onWater
928
+ ? []
929
+ : [
930
+ {
931
+ name: site.name ?? site.id,
932
+ x: site.at.x * width,
933
+ y: site.at.y * height,
934
+ countryTag: site.country,
935
+ },
936
+ ]);
937
+ const namedCities = [...(spec.referenceCities ?? []), ...siteAnchors].map((city) => {
835
938
  const pixel = city.x >= 0 && city.y >= 0 && city.x < width && city.y < height
836
939
  ? Math.floor(city.y) * width + Math.floor(city.x)
837
940
  : undefined;
@@ -844,6 +947,17 @@ export function generateWorld(spec, context, options = {}) {
844
947
  });
845
948
  const capitals = [];
846
949
  const largest = [...landIds].sort((left, right) => provinces.size[right] - provinces.size[left] || left - right);
950
+ const realmRules = spec.realms === undefined
951
+ ? undefined
952
+ : resolveRealmRules(spec.realms, {
953
+ width,
954
+ height,
955
+ pixelProvince: provinces.pixel,
956
+ provinceSize: provinces.size,
957
+ isLand: (id) => provinces.kind[id] === LAND,
958
+ });
959
+ if (realmRules !== undefined)
960
+ capitals.push(...realmRules.capitals);
847
961
  while (capitals.length < countryCount) {
848
962
  const countryIndex = capitals.length;
849
963
  const regionCandidates = largest.filter((id) => referenceOwners.get(id) === countryIndex && !capitals.includes(id));
@@ -879,20 +993,41 @@ export function generateWorld(spec, context, options = {}) {
879
993
  }
880
994
  const shares = spec.countries.map((country) => Math.max(0.05, country.share ?? 1));
881
995
  const owned = new Array(countryCount).fill(0);
882
- for (const [id, country] of referenceOwners) {
996
+ for (const [id, country] of realmRules?.forced ?? referenceOwners) {
883
997
  owner.set(id, country);
884
998
  owned[country] += provinces.size[id];
885
999
  }
1000
+ const unownedLand = realmRules?.unowned ?? new Set();
1001
+ const blockedFor = (index, id) => unownedLand.has(id) || realmRules?.excluded[index].has(id) === true;
886
1002
  const frontiers = capitals.map((capital, index) => {
887
- owner.set(capital, index);
888
- if (!referenceOwners.has(capital))
1003
+ if (!owner.has(capital))
889
1004
  owned[index] += provinces.size[capital];
1005
+ owner.set(capital, index);
1006
+ if (realmRules !== undefined)
1007
+ return [...owner]
1008
+ .filter(([, country]) => country === index)
1009
+ .flatMap(([id]) => [...provinces.neighbors[id]]);
890
1010
  return [...provinces.neighbors[capital]];
891
1011
  });
892
- for (;;) {
1012
+ let grownByTerritory = false;
1013
+ // The feature-aware partition grows generated countries along its natural lines (ADR 0060).
1014
+ // Workspace-owned countries keep the realm growth with its forced and blocked land (ADR 0069).
1015
+ if (featurePartition !== undefined && referenceOwners.size === 0 && realmRules === undefined) {
1016
+ const atoms = buildTerritoryAtoms(territoryGraphFromPartition(featurePartition.graph, provinces.sumX, provinces.sumY), landIds.map((id) => [id]));
1017
+ const grown = growTerritory(atoms, spec.countries.map((country, index) => ({
1018
+ tag: country.tag,
1019
+ weight: shares[index],
1020
+ seeds: [atoms.atomOfProvince[capitals[index]]],
1021
+ })), { seed: spec.seed });
1022
+ for (const [atom, id] of landIds.entries())
1023
+ if (grown.owner[atom] >= 0)
1024
+ owner.set(id, grown.owner[atom]);
1025
+ grownByTerritory = true;
1026
+ }
1027
+ while (!grownByTerritory) {
893
1028
  let chosen = -1;
894
1029
  for (let index = 0; index < countryCount; index += 1) {
895
- frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id));
1030
+ frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id) && !blockedFor(index, id));
896
1031
  if (frontiers[index].length === 0)
897
1032
  continue;
898
1033
  if (chosen < 0 || owned[index] / shares[index] < owned[chosen] / shares[chosen])
@@ -908,9 +1043,21 @@ export function generateWorld(spec, context, options = {}) {
908
1043
  if (!owner.has(neighbor))
909
1044
  frontier.push(neighbor);
910
1045
  }
1046
+ // Land no workspace country reached follows the island rule; the rest of the land is unowned.
1047
+ if (realmRules !== undefined)
1048
+ assignUnreachedRealmLand({
1049
+ landIds,
1050
+ owner,
1051
+ unowned: unownedLand,
1052
+ capitals,
1053
+ excluded: realmRules.excluded,
1054
+ islands: spec.realms?.islands ?? 'nearest',
1055
+ part: (id) => landParts.get(id),
1056
+ center,
1057
+ });
911
1058
  // Islands no country reached go to the nearest capital's country.
912
1059
  for (const id of landIds)
913
- if (!owner.has(id)) {
1060
+ if (!owner.has(id) && !unownedLand.has(id)) {
914
1061
  const c = center(id);
915
1062
  let best = 0;
916
1063
  for (const [index, capital] of capitals.entries())
@@ -946,7 +1093,7 @@ export function generateWorld(spec, context, options = {}) {
946
1093
  // States: within each region and country, clusters of about provincesPerState.
947
1094
  const groups = new Map();
948
1095
  for (const id of landIds) {
949
- const key = `${regionOf.get(id)}:${owner.get(id)}`;
1096
+ const key = `${regionOf.get(id)}:${owner.get(id) ?? -1}`;
950
1097
  const list = groups.get(key) ?? [];
951
1098
  list.push(id);
952
1099
  groups.set(key, list);
@@ -970,7 +1117,7 @@ export function generateWorld(spec, context, options = {}) {
970
1117
  const draft = {
971
1118
  id: states.length + 1,
972
1119
  provinces: sorted,
973
- owner: spec.countries[countryIndex].tag,
1120
+ ...(countryIndex < 0 ? {} : { owner: spec.countries[countryIndex].tag }),
974
1121
  region,
975
1122
  capital: sorted[0],
976
1123
  manpower: 0,
@@ -983,12 +1130,33 @@ export function generateWorld(spec, context, options = {}) {
983
1130
  resources: new Map(),
984
1131
  victoryPoints: new Map(),
985
1132
  navalBases: new Map(),
1133
+ forts: new Map(),
986
1134
  };
987
1135
  states.push(draft);
988
1136
  for (const id of sorted)
989
1137
  stateOf.set(id, draft);
990
1138
  }
991
1139
  }
1140
+ // Names from reference labels (the nearest province to each label), which win over any other.
1141
+ const stateNames = new Map();
1142
+ const regionNames = new Map();
1143
+ for (const label of spec.referenceLabels ?? []) {
1144
+ if (label.kind === 'state') {
1145
+ const nearby = nearestProvince(label, landIds);
1146
+ const state = stateOf.get(nearby);
1147
+ const previous = stateNames.get(state.id);
1148
+ stateNames.set(state.id, previous === undefined ? label.name : `${previous} / ${label.name}`);
1149
+ }
1150
+ else if (label.kind === 'sea' || label.kind === 'region') {
1151
+ const members = label.kind === 'sea' ? seaIds : landIds;
1152
+ if (members.length === 0)
1153
+ continue;
1154
+ const region = regionOf.get(nearestProvince(label, members));
1155
+ const previous = regionNames.get(region);
1156
+ regionNames.set(region, previous === undefined ? label.name : `${previous} / ${label.name}`);
1157
+ }
1158
+ }
1159
+ const lakeNames = new Map();
992
1160
  // State values from size, terrain and development, with the country capital boosted.
993
1161
  const categories = [
994
1162
  'wasteland',
@@ -1016,112 +1184,153 @@ export function generateWorld(spec, context, options = {}) {
1016
1184
  state.capital = capital;
1017
1185
  countryCapitalState.set(spec.countries[index].tag, state);
1018
1186
  }
1019
- for (const state of states) {
1020
- const pixels = state.provinces.reduce((sum, id) => sum + provinces.size[id], 0);
1021
- const habitability = state.provinces.reduce((sum, id) => {
1022
- const value = {
1023
- plains: 1,
1024
- forest: 0.8,
1025
- hills: 0.6,
1026
- jungle: 0.5,
1027
- marsh: 0.4,
1028
- desert: 0.25,
1029
- mountain: 0.3,
1030
- urban: 1.5,
1031
- }[terrain.get(id) ?? 'plains'] ?? 0.5;
1032
- return sum + value * provinces.size[id];
1033
- }, 0) / pixels;
1034
- const isCapital = countryCapitalState.get(state.owner) === state;
1035
- const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
1036
- state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
1037
- const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
1038
- state.category = categories[categoryIndex];
1039
- if (context.stateCategories !== undefined && !context.stateCategories.has(state.category))
1040
- state.category = context.stateCategories.has('rural')
1041
- ? 'rural'
1042
- : ([...context.stateCategories][0] ?? 'rural');
1043
- const slots = slotsFor[state.category] ?? 2;
1044
- state.infrastructure = Math.max(1, Math.min(5, Math.round(1 + development * 3 + (isCapital ? 1 : 0))));
1045
- state.civilian = Math.round(slots * (0.25 + random.next() * 0.3));
1046
- state.military = Math.min(slots - state.civilian, Math.round(slots * random.next() * 0.3));
1047
- if (isCapital) {
1048
- terrain.set(state.capital, 'urban');
1049
- state.airBase = 2;
1050
- }
1051
- const coastalProvinces = state.provinces.filter(coastal);
1052
- if (coastalProvinces.length > 0 && (isCapital || random.next() < 0.35)) {
1053
- state.navalBases.set(coastalProvinces[0], isCapital ? 3 : 1);
1054
- if (slots - state.civilian - state.military > 0 && random.next() < 0.5)
1055
- state.dockyard = 1;
1056
- }
1057
- for (const id of state.provinces) {
1058
- const kindOfLand = terrain.get(id);
1059
- const roll = random.next();
1060
- const add = (resource, amount) => state.resources.set(resource, (state.resources.get(resource) ?? 0) + amount);
1061
- if (kindOfLand === 'desert' && roll < 0.3)
1062
- add('oil', 2 + random.int(6));
1063
- if ((kindOfLand === 'mountain' || kindOfLand === 'hills') && roll < 0.35)
1064
- add('steel', 2 + random.int(8));
1065
- if (kindOfLand === 'mountain' && roll < 0.15)
1066
- add('tungsten', 1 + random.int(4));
1067
- if (kindOfLand === 'hills' && roll > 0.85)
1068
- add('chromium', 1 + random.int(4));
1069
- if (kindOfLand === 'jungle' && roll < 0.35)
1070
- add('rubber', 1 + random.int(5));
1071
- if (kindOfLand === 'plains' && roll < 0.05)
1072
- add('aluminium', 1 + random.int(4));
1073
- }
1074
- state.victoryPoints.set(state.capital, isCapital ? 25 : Math.max(1, Math.round(development * 8)));
1075
- }
1076
1187
  const cityNames = new Map();
1077
- for (const city of namedCities) {
1078
- const state = stateOf.get(city.province);
1079
- cityNames.set(city.province, city.name);
1080
- state.victoryPoints.set(city.province, Math.max(5, state.victoryPoints.get(city.province) ?? 0));
1081
- terrain.set(city.province, 'urban');
1082
- }
1083
- // Each coastal territory needs a port even when its capital lies inland or on another island.
1084
- const portChecked = new Set();
1085
- for (const start of landIds) {
1086
- if (portChecked.has(start))
1087
- continue;
1088
- const queue = [start];
1089
- portChecked.add(start);
1090
- let bestCoast;
1091
- let hasPort = false;
1092
- for (const id of queue) {
1093
- if (stateOf.get(id).navalBases.has(id))
1094
- hasPort = true;
1095
- if (coastal(id) &&
1096
- (bestCoast === undefined || provinces.size[id] > provinces.size[bestCoast]))
1097
- bestCoast = id;
1098
- for (const next of provinces.neighbors[id])
1099
- if (!portChecked.has(next) &&
1100
- provinces.kind[next] === LAND &&
1101
- owner.get(next) === owner.get(start)) {
1102
- portChecked.add(next);
1103
- queue.push(next);
1104
- }
1188
+ // A geography world gets its economy, ports, names and railway costs from the model (ADR 0063);
1189
+ // every other world keeps the random economy below, byte for byte.
1190
+ const economy = gameplay === undefined || spec.developmentModel === 'legacy'
1191
+ ? undefined
1192
+ : planEconomy(gameplay, {
1193
+ states: states.map((state) => ({
1194
+ id: state.id,
1195
+ provinces: state.provinces,
1196
+ owner: state.owner,
1197
+ isCapital: state.owner !== undefined && countryCapitalState.get(state.owner) === state,
1198
+ })),
1199
+ terrain,
1200
+ options: {
1201
+ categories,
1202
+ slotsFor,
1203
+ allowedCategories: context.stateCategories,
1204
+ capitalProvinces: new Set(capitals),
1205
+ },
1206
+ regionOf,
1207
+ fixedStateNames: stateNames,
1208
+ fixedRegionNames: regionNames,
1209
+ countryNames: new Map(spec.countries.map(({ tag, name }) => [tag, name])),
1210
+ });
1211
+ if (economy !== undefined) {
1212
+ for (const state of states) {
1213
+ const plan = economy.development.states.get(state.id);
1214
+ state.manpower = plan.manpower;
1215
+ state.category = plan.category;
1216
+ state.infrastructure = plan.infrastructure;
1217
+ state.civilian = plan.civilian;
1218
+ state.military = plan.military;
1219
+ state.dockyard = plan.dockyard;
1220
+ state.airBase = plan.airBase;
1221
+ state.resources = plan.resources;
1222
+ state.victoryPoints = plan.victoryPoints;
1223
+ for (const id of state.provinces) {
1224
+ const port = economy.ports.ports.get(id);
1225
+ if (port !== undefined)
1226
+ state.navalBases.set(id, port.level);
1227
+ const fort = economy.forts.get(id);
1228
+ if (fort !== undefined)
1229
+ state.forts.set(id, fort);
1230
+ }
1105
1231
  }
1106
- if (!hasPort && bestCoast !== undefined)
1107
- stateOf.get(bestCoast).navalBases.set(bestCoast, 1);
1232
+ for (const id of economy.development.urban)
1233
+ terrain.set(id, 'urban');
1234
+ for (const [id, name] of economy.names.victory)
1235
+ cityNames.set(id, name);
1236
+ for (const [id, name] of economy.names.states)
1237
+ stateNames.set(id, name);
1238
+ for (const [id, name] of economy.names.regions)
1239
+ regionNames.set(id, name);
1240
+ for (const [id, name] of economy.names.lakes)
1241
+ lakeNames.set(id, name);
1242
+ for (const note of economy.notes)
1243
+ notes.push(note);
1108
1244
  }
1109
- const stateNames = new Map();
1110
- const regionNames = new Map();
1111
- for (const label of spec.referenceLabels ?? []) {
1112
- if (label.kind === 'state') {
1113
- const nearby = nearestProvince(label, landIds);
1114
- const state = stateOf.get(nearby);
1115
- const previous = stateNames.get(state.id);
1116
- stateNames.set(state.id, previous === undefined ? label.name : `${previous} / ${label.name}`);
1245
+ if (economy === undefined) {
1246
+ for (const state of states) {
1247
+ const pixels = state.provinces.reduce((sum, id) => sum + provinces.size[id], 0);
1248
+ const habitability = state.provinces.reduce((sum, id) => {
1249
+ const value = {
1250
+ plains: 1,
1251
+ forest: 0.8,
1252
+ hills: 0.6,
1253
+ jungle: 0.5,
1254
+ marsh: 0.4,
1255
+ desert: 0.25,
1256
+ mountain: 0.3,
1257
+ urban: 1.5,
1258
+ }[terrain.get(id) ?? 'plains'] ?? 0.5;
1259
+ return sum + value * provinces.size[id];
1260
+ }, 0) / pixels;
1261
+ const isCapital = state.owner !== undefined && countryCapitalState.get(state.owner) === state;
1262
+ const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
1263
+ state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
1264
+ const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
1265
+ state.category = categories[categoryIndex];
1266
+ if (context.stateCategories !== undefined && !context.stateCategories.has(state.category))
1267
+ state.category = context.stateCategories.has('rural')
1268
+ ? 'rural'
1269
+ : ([...context.stateCategories][0] ?? 'rural');
1270
+ const slots = slotsFor[state.category] ?? 2;
1271
+ state.infrastructure = Math.max(1, Math.min(5, Math.round(1 + development * 3 + (isCapital ? 1 : 0))));
1272
+ state.civilian = Math.round(slots * (0.25 + random.next() * 0.3));
1273
+ state.military = Math.min(slots - state.civilian, Math.round(slots * random.next() * 0.3));
1274
+ if (isCapital) {
1275
+ terrain.set(state.capital, 'urban');
1276
+ state.airBase = 2;
1277
+ }
1278
+ const coastalProvinces = state.provinces.filter(coastal);
1279
+ if (coastalProvinces.length > 0 && (isCapital || random.next() < 0.35)) {
1280
+ state.navalBases.set(coastalProvinces[0], isCapital ? 3 : 1);
1281
+ if (slots - state.civilian - state.military > 0 && random.next() < 0.5)
1282
+ state.dockyard = 1;
1283
+ }
1284
+ for (const id of state.provinces) {
1285
+ const kindOfLand = terrain.get(id);
1286
+ const roll = random.next();
1287
+ const add = (resource, amount) => state.resources.set(resource, (state.resources.get(resource) ?? 0) + amount);
1288
+ if (kindOfLand === 'desert' && roll < 0.3)
1289
+ add('oil', 2 + random.int(6));
1290
+ if ((kindOfLand === 'mountain' || kindOfLand === 'hills') && roll < 0.35)
1291
+ add('steel', 2 + random.int(8));
1292
+ if (kindOfLand === 'mountain' && roll < 0.15)
1293
+ add('tungsten', 1 + random.int(4));
1294
+ if (kindOfLand === 'hills' && roll > 0.85)
1295
+ add('chromium', 1 + random.int(4));
1296
+ if (kindOfLand === 'jungle' && roll < 0.35)
1297
+ add('rubber', 1 + random.int(5));
1298
+ if (kindOfLand === 'plains' && roll < 0.05)
1299
+ add('aluminium', 1 + random.int(4));
1300
+ }
1301
+ state.victoryPoints.set(state.capital, isCapital ? 25 : Math.max(1, Math.round(development * 8)));
1117
1302
  }
1118
- else if (label.kind === 'sea' || label.kind === 'region') {
1119
- const members = label.kind === 'sea' ? seaIds : landIds;
1120
- if (members.length === 0)
1303
+ for (const city of namedCities) {
1304
+ const state = stateOf.get(city.province);
1305
+ cityNames.set(city.province, city.name);
1306
+ state.victoryPoints.set(city.province, Math.max(5, state.victoryPoints.get(city.province) ?? 0));
1307
+ terrain.set(city.province, 'urban');
1308
+ }
1309
+ // Each coastal territory needs a port even when its capital lies inland or on another island.
1310
+ const portChecked = new Set();
1311
+ for (const start of landIds) {
1312
+ if (portChecked.has(start))
1121
1313
  continue;
1122
- const region = regionOf.get(nearestProvince(label, members));
1123
- const previous = regionNames.get(region);
1124
- regionNames.set(region, previous === undefined ? label.name : `${previous} / ${label.name}`);
1314
+ const queue = [start];
1315
+ portChecked.add(start);
1316
+ let bestCoast;
1317
+ let hasPort = false;
1318
+ for (const id of queue) {
1319
+ if (stateOf.get(id).navalBases.has(id))
1320
+ hasPort = true;
1321
+ if (coastal(id) &&
1322
+ (bestCoast === undefined || provinces.size[id] > provinces.size[bestCoast]))
1323
+ bestCoast = id;
1324
+ for (const next of provinces.neighbors[id])
1325
+ if (!portChecked.has(next) &&
1326
+ provinces.kind[next] === LAND &&
1327
+ owner.get(next) === owner.get(start)) {
1328
+ portChecked.add(next);
1329
+ queue.push(next);
1330
+ }
1331
+ }
1332
+ if (!hasPort && bestCoast !== undefined)
1333
+ stateOf.get(bestCoast).navalBases.set(bestCoast, 1);
1125
1334
  }
1126
1335
  }
1127
1336
  // Province IDs: 1-based in reading order of the province's first pixel; 0 is the null row.
@@ -1140,6 +1349,7 @@ export function generateWorld(spec, context, options = {}) {
1140
1349
  const files = [];
1141
1350
  const add = (relativePath, content, mediaType = 'text/plain') => files.push({ relativePath, content, mediaType });
1142
1351
  const countryNames = new Map(spec.countries.map(({ tag, name }) => [tag, name]));
1352
+ const ownerName = (state) => state.owner === undefined ? 'Free Land' : (countryNames.get(state.owner) ?? state.owner);
1143
1353
  const map = context.mapRoot;
1144
1354
  // provinces.bmp
1145
1355
  const rgb = new Array(width * height);
@@ -1189,8 +1399,11 @@ export function generateWorld(spec, context, options = {}) {
1189
1399
  message: `The rim edge policy wrote ${rimRows.length} impassable adjacency row(s) for provinces that meet across the seam`,
1190
1400
  details: { rows: rimRows.length },
1191
1401
  });
1192
- add(`${map}/adjacencies.csv`, text(`From;To;Type;Through;start_x;start_y;stop_x;stop_y;adjacency_rule_name;Comment\r\n${rimRows.map((row) => `${row}\r\n`).join('')}-1;-1;;-1;-1;-1;-1;-1;-1\r\n`));
1193
- add(`${map}/adjacency_rules.txt`, text('# No straits or canals in this world yet.\n'));
1402
+ const crossingRows = (gameplay?.crossings.rows ?? []).map(adjacencyRowText);
1403
+ add(`${map}/adjacencies.csv`, text(`From;To;Type;Through;start_x;start_y;stop_x;stop_y;adjacency_rule_name;Comment\r\n${[...crossingRows, ...rimRows].map((row) => `${row}\r\n`).join('')}-1;-1;;-1;-1;-1;-1;-1;-1\r\n`));
1404
+ add(`${map}/adjacency_rules.txt`, text(gameplay === undefined
1405
+ ? '# No straits or canals in this world yet.\n'
1406
+ : adjacencyRulesText(gameplay.crossings.rules)));
1194
1407
  add(`${map}/positions.txt`, text(''));
1195
1408
  add(`${map}/ambient_object.txt`, text(spec.ambientFrame === true
1196
1409
  ? ambientObjectText(width, height)
@@ -1529,7 +1742,16 @@ export function generateWorld(spec, context, options = {}) {
1529
1742
  ...(state.military > 0 ? [`\t\t\tarms_factory = ${state.military}`] : []),
1530
1743
  ...(state.dockyard > 0 ? [`\t\t\tdockyard = ${state.dockyard}`] : []),
1531
1744
  ...(state.airBase > 0 ? [`\t\t\tair_base = ${state.airBase}`] : []),
1532
- ...[...state.navalBases].map(([id, level]) => `\t\t\t${provinceId(id)} = { naval_base = ${level} }`),
1745
+ ...[
1746
+ ...state.navalBases.keys(),
1747
+ ...[...state.forts.keys()].filter((id) => !state.navalBases.has(id)).sort((a, b) => a - b),
1748
+ ].map((id) => {
1749
+ const parts = [
1750
+ ...(state.navalBases.has(id) ? [`naval_base = ${state.navalBases.get(id)}`] : []),
1751
+ ...(state.forts.has(id) ? [`bunker = ${state.forts.get(id)}`] : []),
1752
+ ];
1753
+ return `\t\t\t${provinceId(id)} = { ${parts.join(' ')} }`;
1754
+ }),
1533
1755
  ];
1534
1756
  add(`${context.statesRoot}/${state.id}-${slug(stateNames.get(state.id) ?? `STATE_${state.id}`).slice(0, 80)}.txt`, text([
1535
1757
  'state = {',
@@ -1543,8 +1765,9 @@ export function generateWorld(spec, context, options = {}) {
1543
1765
  `\tresources = {\n${resources.map(([resource, amount]) => `\t\t${resource} = ${amount}`).join('\n')}\n\t}`,
1544
1766
  ]),
1545
1767
  '\thistory = {',
1546
- `\t\towner = ${state.owner}`,
1547
- `\t\tadd_core_of = ${state.owner}`,
1768
+ ...(state.owner === undefined
1769
+ ? []
1770
+ : [`\t\towner = ${state.owner}`, `\t\tadd_core_of = ${state.owner}`]),
1548
1771
  ...[...state.victoryPoints].map(([id, value]) => `\t\tvictory_points = { ${provinceId(id)} ${value} }`),
1549
1772
  '\t\tbuildings = {',
1550
1773
  ...buildings,
@@ -1559,7 +1782,8 @@ export function generateWorld(spec, context, options = {}) {
1559
1782
  const countryLocalisation = [];
1560
1783
  const countryLines = [];
1561
1784
  const countryColors = [];
1562
- for (const [index, country] of spec.countries.entries()) {
1785
+ // Workspace-owned countries keep their own tags, files, histories, flags and names.
1786
+ for (const [index, country] of (spec.realms === undefined ? spec.countries : []).entries()) {
1563
1787
  const color = country.color ?? {
1564
1788
  r: 60 + random.int(170),
1565
1789
  g: 60 + random.int(170),
@@ -1658,10 +1882,12 @@ export function generateWorld(spec, context, options = {}) {
1658
1882
  add(`gfx/flags/${folder}${country.tag}.tga`, encodeTga(flagWidth, flagHeight, (_x, y) => y < flagHeight / 3 ? light : y < (flagHeight * 2) / 3 ? color : dark), 'image/x-tga');
1659
1883
  void index;
1660
1884
  }
1661
- add('common/country_tags/zz_world_countries.txt', text(`${countryLines.join('\n')}\n`));
1662
- add('common/countries/colors.txt', text(`${countryColors.join('\n')}\n`));
1885
+ if (spec.realms === undefined) {
1886
+ add('common/country_tags/zz_world_countries.txt', text(`${countryLines.join('\n')}\n`));
1887
+ add('common/countries/colors.txt', text(`${countryColors.join('\n')}\n`));
1888
+ }
1663
1889
  if (spec.startingScenario) {
1664
- for (const tag of context.inheritedCountryTags ?? [])
1890
+ for (const tag of spec.realms === undefined ? (context.inheritedCountryTags ?? []) : [])
1665
1891
  if (!countryNames.has(tag))
1666
1892
  add(`history/countries/${tag} - inactive world.txt`, text('# Inherited tag has no territory or active starting country in this world.\n'));
1667
1893
  add('common/on_actions/world.txt', text('# Old-world startup hooks are replaced for this generated map.\non_actions = { }\n'));
@@ -1675,44 +1901,70 @@ export function generateWorld(spec, context, options = {}) {
1675
1901
  states
1676
1902
  .filter((state) => state.owner === left.tag)
1677
1903
  .reduce((sum, state) => sum + state.manpower, 0) || (left.tag < right.tag ? -1 : 1));
1678
- const majors = ranked.slice(0, Math.min(7, ranked.length));
1679
- add('common/bookmarks/world.txt', text([
1680
- 'bookmarks = {',
1681
- '\tbookmark = {',
1682
- '\t\tname = "WORLD_BOOKMARK_NAME"',
1683
- '\t\tdesc = "WORLD_BOOKMARK_DESC"',
1684
- `\t\tdate = ${spec.startDate}.12`,
1685
- '\t\tpicture = "GFX_select_date_1936"',
1686
- `\t\tdefault_country = "${majors[0].tag}"`,
1687
- '\t\tdefault = yes',
1688
- ...majors.map((country) => `\t\t"${country.tag}" = {\n\t\t\thistory = "${country.tag}_WORLD_DESC"\n\t\t\tideology = ${country.ideology ?? 'neutrality'}\n\t\t}`),
1689
- '\t\t"---" = {\n\t\t\thistory = "WORLD_OTHER_DESC"\n\t\t}',
1690
- '\t\teffect = {\n\t\t\trandomize_weather = 22345\n\t\t}',
1691
- '\t}',
1692
- '}',
1693
- '',
1694
- ].join('\n')));
1904
+ const majors = spec.realms === undefined ? ranked.slice(0, Math.min(7, ranked.length)) : [];
1905
+ if (spec.realms === undefined)
1906
+ add('common/bookmarks/world.txt', text([
1907
+ 'bookmarks = {',
1908
+ '\tbookmark = {',
1909
+ '\t\tname = "WORLD_BOOKMARK_NAME"',
1910
+ '\t\tdesc = "WORLD_BOOKMARK_DESC"',
1911
+ `\t\tdate = ${spec.startDate}.12`,
1912
+ '\t\tpicture = "GFX_select_date_1936"',
1913
+ `\t\tdefault_country = "${majors[0].tag}"`,
1914
+ '\t\tdefault = yes',
1915
+ ...majors.map((country) => `\t\t"${country.tag}" = {\n\t\t\thistory = "${country.tag}_WORLD_DESC"\n\t\t\tideology = ${country.ideology ?? 'neutrality'}\n\t\t}`),
1916
+ '\t\t"---" = {\n\t\t\thistory = "WORLD_OTHER_DESC"\n\t\t}',
1917
+ '\t\teffect = {\n\t\t\trandomize_weather = 22345\n\t\t}',
1918
+ '\t}',
1919
+ '}',
1920
+ '',
1921
+ ].join('\n')));
1922
+ const realmSummary = () => {
1923
+ const landPixels = landIds.reduce((sum, id) => sum + provinces.size[id], 0);
1924
+ const weights = spec.countries.reduce((sum, country) => sum + (country.share ?? 1), 0);
1925
+ return {
1926
+ countries: spec.countries.map((country, index) => {
1927
+ const ownedStates = states.filter((state) => state.owner === country.tag);
1928
+ const pixels = ownedStates.reduce((sum, state) => sum + state.provinces.reduce((inner, id) => inner + provinces.size[id], 0), 0);
1929
+ return {
1930
+ tag: country.tag,
1931
+ capitalState: countryCapitalState.get(country.tag).id,
1932
+ capitalProvince: provinceId(capitals[index]),
1933
+ states: ownedStates.map(({ id }) => id),
1934
+ provinces: ownedStates.reduce((sum, state) => sum + state.provinces.length, 0),
1935
+ landShare: Math.round((pixels / landPixels) * 10_000) / 10_000,
1936
+ targetShare: Math.round(((country.share ?? 1) / weights) * 10_000) / 10_000,
1937
+ };
1938
+ }),
1939
+ unownedStates: states.filter((state) => state.owner === undefined).map(({ id }) => id),
1940
+ };
1941
+ };
1695
1942
  // Localisation for everything named above.
1696
1943
  add(`${context.localisationRoot}/replace/world_l_english.yml`, localisation([
1697
1944
  ...states.map((state) => [
1698
1945
  `STATE_${state.id}`,
1699
- stateNames.get(state.id) ??
1700
- `${countryNames.get(state.owner) ?? state.owner} District ${state.id}`,
1946
+ stateNames.get(state.id) ?? `${ownerName(state)} District ${state.id}`,
1701
1947
  ]),
1702
1948
  ...states.flatMap((state) => [...state.victoryPoints.keys()].map((id) => [
1703
1949
  `VICTORY_POINTS_${provinceId(id)}`,
1704
- cityNames.get(id) ??
1705
- `${countryNames.get(state.owner) ?? state.owner} Settlement ${provinceId(id)}`,
1950
+ cityNames.get(id) ?? `${ownerName(state)} Settlement ${provinceId(id)}`,
1706
1951
  ])),
1707
1952
  ...[...regionMembers.keys()]
1708
1953
  .sort((a, b) => a - b)
1709
1954
  .map((region) => [`STRATEGICREGION_${region}`, regionNames.get(region) ?? `Region ${region}`]),
1710
1955
  ...(spec.referenceLabels ?? []).map((label, index) => [`WORLD_GEOGRAPHY_${index + 1}`, label.name]),
1956
+ ...[...lakeNames]
1957
+ .sort((a, b) => a[0] - b[0])
1958
+ .map(([id, name]) => [`PROV${provinceId(id)}`, name]),
1711
1959
  ...continentNames.map((name, index) => [`world_continent_${index + 1}`, name]),
1712
1960
  ...countryLocalisation,
1713
- ['WORLD_BOOKMARK_NAME', spec.worldName ?? 'A New World'],
1714
- ['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
1715
- ['WORLD_OTHER_DESC', 'Any other nation of this world.'],
1961
+ ...(spec.realms === undefined
1962
+ ? [
1963
+ ['WORLD_BOOKMARK_NAME', spec.worldName ?? 'A New World'],
1964
+ ['WORLD_BOOKMARK_DESC', 'Nations of an unfamiliar map at the dawn of the war.'],
1965
+ ['WORLD_OTHER_DESC', 'Any other nation of this world.'],
1966
+ ]
1967
+ : []),
1716
1968
  ...majors.map((country) => [
1717
1969
  `${country.tag}_WORLD_DESC`,
1718
1970
  `${country.name} begins the game among the strongest nations.`,
@@ -1723,6 +1975,8 @@ export function generateWorld(spec, context, options = {}) {
1723
1975
  warnings: [...interpretationWarnings, ...drainage.warnings],
1724
1976
  notes,
1725
1977
  ...(synthesized === undefined ? {} : { terrain: synthesized }),
1978
+ ...(economy === undefined ? {} : { railCost: economy.railCost }),
1979
+ ...(gameplay === undefined ? {} : { gameplay: { crossings: gameplay.crossings, economy } }),
1726
1980
  summary: {
1727
1981
  width,
1728
1982
  height,
@@ -1735,5 +1989,6 @@ export function generateWorld(spec, context, options = {}) {
1735
1989
  countries: spec.countries.length,
1736
1990
  rivers: riverCount,
1737
1991
  },
1992
+ ...(spec.realms === undefined ? {} : { realms: realmSummary() }),
1738
1993
  };
1739
1994
  }