hoi4-agent-tools 4.5.0 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  3. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  4. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  5. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  6. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  7. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  8. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  9. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  10. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  16. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  17. package/dist/hoi4_agent_tools/map/operations.js +109 -32
  18. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  19. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +107 -0
  20. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +52 -0
  21. package/dist/hoi4_agent_tools/map/service.js +78 -16
  22. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  23. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  24. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  25. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  26. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  27. package/dist/hoi4_agent_tools/map/tool-operations.js +78 -0
  28. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  29. package/dist/hoi4_agent_tools/map/world.js +301 -107
  30. package/dist/hoi4_agent_tools/reference/known-mistakes.js +3 -0
  31. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  32. package/dist/hoi4_agent_tools/schemas/map.js +206 -21
  33. package/dist/hoi4_agent_tools/version.js +1 -1
  34. package/docs/map.md +100 -4
  35. package/npm-shrinkwrap.json +2 -2
  36. package/package.json +1 -1
  37. package/schemas/configuration.schema.json +1 -1
  38. package/schemas/continuous-focus-palette.schema.json +1 -1
  39. package/schemas/custom-weighted-pool.schema.json +1 -1
  40. package/schemas/decision-inspect-input.schema.json +1 -1
  41. package/schemas/error-log-input.schema.json +1 -1
  42. package/schemas/event-feature-manifest.schema.json +1 -1
  43. package/schemas/focus-plan.schema.json +1 -1
  44. package/schemas/focus-planning-sidecar.schema.json +1 -1
  45. package/schemas/geography-model.schema.json +13 -1
  46. package/schemas/gui-animation-source.schema.json +1 -1
  47. package/schemas/gui-helper.schema.json +1 -1
  48. package/schemas/gui-scenario.schema.json +1 -1
  49. package/schemas/helper-expansion-request.schema.json +1 -1
  50. package/schemas/helper-expansion-summary.schema.json +1 -1
  51. package/schemas/impact-inspect-input.schema.json +1 -1
  52. package/schemas/map-operation.schema.json +1355 -736
  53. package/schemas/mechanic-test-input.schema.json +1 -1
  54. package/schemas/mod-index-input.schema.json +1 -1
  55. package/schemas/operation-result.schema.json +1 -1
  56. package/schemas/package-check-input.schema.json +1 -1
  57. package/schemas/package-manifest.schema.json +1 -1
  58. package/schemas/probability-analysis-result.schema.json +1 -1
  59. package/schemas/probability-compare-input.schema.json +1 -1
  60. package/schemas/probability-evaluate-input.schema.json +1 -1
  61. package/schemas/probability-inspect-input.schema.json +1 -1
  62. package/schemas/probability-render-input.schema.json +1 -1
  63. package/schemas/probability-scenario-set.schema.json +1 -1
  64. package/schemas/probability-sequence-input.schema.json +1 -1
  65. package/schemas/probability-simulate-input.schema.json +1 -1
  66. package/schemas/probability-sweep-input.schema.json +1 -1
  67. package/schemas/reference-context-input.schema.json +1 -1
  68. package/schemas/reference-read-input.schema.json +1 -1
  69. package/schemas/reference-search-input.schema.json +1 -1
  70. package/schemas/reference-sheets.schema.json +1 -1
  71. package/schemas/scenario-suite.schema.json +1 -1
  72. package/schemas/scenario-test-input.schema.json +1 -1
  73. package/schemas/script-validate-input.schema.json +1 -1
  74. package/schemas/shared-scenario.schema.json +1 -1
  75. package/schemas/source-lookup-input.schema.json +1 -1
  76. package/server.json +2 -2
@@ -7,6 +7,7 @@ import { emptyServiceResult, ServiceError } from '../core/result.js';
7
7
  import { setInlineFilesScanned } from '../core/operation-result.js';
8
8
  import { PACKAGE_VERSION } from '../version.js';
9
9
  import { allocateMapIdentifiersAsync, WORLD_OUTPUT_PATH_SAMPLE, } from './operations.js';
10
+ import { compactTerritoryReport } from './territory-operations.js';
10
11
  import { borderQualityDiagnostics } from './border-quality.js';
11
12
  import { measureIndexBorderNaturalness, summarizeBorderQuality, } from './border-quality-input.js';
12
13
  import { buildMapCatalog, lookupMapCoordinate, searchMapCatalog } from './catalog.js';
@@ -301,6 +302,9 @@ export async function prepareMapRewrite(nudger, input, context) {
301
302
  semanticDiff: compactSemantic.semantic,
302
303
  semanticDiffCounts: compactSemantic.counts,
303
304
  semanticDiffTruncated: compactSemantic.truncated,
305
+ ...(planned.plan.territory === undefined
306
+ ? {}
307
+ : { territory: planned.plan.territory.map(compactTerritoryReport) }),
304
308
  ...worldOutputsData(planned.plan),
305
309
  }),
306
310
  filesScanned: planned.filesScanned,
@@ -503,8 +507,79 @@ async function previewGeographyRewrite(nudger, input, context) {
503
507
  await context.progress(3, 3, 'Geography model preview complete; workspace files were not changed');
504
508
  return result;
505
509
  }
510
+ /**
511
+ * `create_world.synthesisCheck`: run the model's relief and river synthesis only and report every
512
+ * river that cannot reach its mouth, with its divide. No map is generated and nothing is written.
513
+ */
514
+ async function previewSynthesisCheck(nudger, input, context) {
515
+ await context.progress(0, 2, 'Synthesising relief and rivers to check every river');
516
+ const checked = await nudger.checkSynthesis({
517
+ workspaceId: context.workspaceId,
518
+ operations: input.operations,
519
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
520
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
521
+ });
522
+ const blocked = checked.diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
523
+ const valid = blocked.length === 0 && checked.plan.blockers.length === 0;
524
+ const rivers = checked.diagnostics.flatMap(({ code, details }) => code === 'MAP_RIVER_UNREACHABLE_MOUTH' && Array.isArray(details?.rivers)
525
+ ? details.rivers
526
+ : []);
527
+ // Drawn rivers are authoritative: crossings of relief they cut through are warnings (ADR 0071).
528
+ const carved = checked.diagnostics.flatMap(({ code, details }) => code === 'MAP_RIVER_CARVED_CROSSING' && details !== undefined ? [details] : []);
529
+ const compactSemantic = compactMapSemanticDiff(EMPTY_SEMANTIC_DIFF);
530
+ const result = emptyServiceResult(context.workspaceId, {
531
+ execution: 'preview',
532
+ allocations: [],
533
+ operationBlockers: [],
534
+ expectedChangedBounds: null,
535
+ changedProvinceIds: [],
536
+ changedProvinceCount: 0,
537
+ semanticDiff: compactSemantic.semantic,
538
+ semanticDiffCounts: compactSemantic.counts,
539
+ semanticDiffTruncated: compactSemantic.truncated,
540
+ fileCount: 0,
541
+ artifactCount: checked.artifacts.length,
542
+ geography: checked.geography.slice(0, 16).map(geographySummary),
543
+ synthesisCheck: {
544
+ passed: valid,
545
+ unreachableRivers: rivers.length,
546
+ rivers: rivers.slice(0, 200),
547
+ carvedCrossings: carved.length,
548
+ crossings: carved.slice(0, 200),
549
+ },
550
+ });
551
+ result.status = valid ? 'ok' : 'blocked';
552
+ result.code = valid ? 'MAP_SYNTHESIS_CHECK_READY' : 'MAP_SYNTHESIS_CHECK_BLOCKED';
553
+ setInlineFilesScanned(result, checked.filesScanned);
554
+ result.proposedFiles = [];
555
+ result.diagnostics = checked.diagnostics.slice(0, 100);
556
+ result.artifacts = checked.artifacts.map(publicArtifactLink);
557
+ result.validation = {
558
+ passed: valid,
559
+ checks: [
560
+ {
561
+ id: 'river-synthesis',
562
+ passed: valid,
563
+ message: valid
564
+ ? 'Every river of the model reaches its mouth'
565
+ : `${rivers.length || blocked.length} river(s) or model parts block the synthesis; see MAP_RIVER_UNREACHABLE_MOUTH details.rivers`,
566
+ },
567
+ ],
568
+ };
569
+ result.blockers = [
570
+ ...checked.plan.blockers.map(({ code, message, details }) => ({
571
+ code,
572
+ message,
573
+ ...(details === undefined ? {} : { details }),
574
+ })),
575
+ ].slice(0, 100);
576
+ await context.progress(2, 2, 'River check complete; nothing was written');
577
+ return result;
578
+ }
506
579
  /** Generate and validate the same proposed map without creating a write transaction. */
507
580
  export async function previewMapRewrite(nudger, input, context) {
581
+ if (input.operations.some((operation) => operation.kind === 'create_world' && operation.synthesisCheck === true))
582
+ return previewSynthesisCheck(nudger, input, context);
508
583
  if (input.operations.some((operation) => operation.kind === 'create_world' && operationHasGeography(operation)))
509
584
  return previewGeographyRewrite(nudger, input, context);
510
585
  await context.progress(0, 5, 'Generating and validating an offline map preview');
@@ -529,6 +604,9 @@ export async function previewMapRewrite(nudger, input, context) {
529
604
  semanticDiff: compactSemantic.semantic,
530
605
  semanticDiffCounts: compactSemantic.counts,
531
606
  semanticDiffTruncated: compactSemantic.truncated,
607
+ ...(planned.plan.territory === undefined
608
+ ? {}
609
+ : { territory: planned.plan.territory.map(compactTerritoryReport) }),
532
610
  fileCount: planned.plan.changes.length,
533
611
  artifactCount: planned.artifacts.length,
534
612
  ...worldOutputsData(planned.plan),
@@ -3,6 +3,7 @@ import { sortDiagnostics } from '../core/diagnostics.js';
3
3
  import { assertRenderDimensions } from '../core/render-budget.js';
4
4
  import { addActiveMapDiagnostic, addMapDiagnostic } from './diagnostic-limit.js';
5
5
  import { validateAppearance } from './appearance-validation.js';
6
+ import { validateCrossings } from './gameplay/crossing-validation.js';
6
7
  import { rgbKey } from './bmp.js';
7
8
  import { MAP_PROVINCE_ENGINE_MAX_PIXELS } from './model.js';
8
9
  const MAP_DIAGNOSTIC_DETAIL_SAMPLE_LIMIT = 100;
@@ -1206,6 +1207,8 @@ function* validateMapSteps(index, options = {}) {
1206
1207
  yield* cancellationCheckpoint(options.signal);
1207
1208
  yield* validateNetworksAndAdjacencies(index, diagnostics, options);
1208
1209
  yield* cancellationCheckpoint(options.signal);
1210
+ yield* validateCrossings(index, (diagnostic) => addDiagnostic(diagnostics, options, { ...diagnostic, category: 'map' }));
1211
+ yield* cancellationCheckpoint(options.signal);
1209
1212
  yield* validatePositions(index, diagnostics, options);
1210
1213
  yield* cancellationCheckpoint(options.signal);
1211
1214
  yield* validateAppearance(index, (diagnostic) => addDiagnostic(diagnostics, options, { ...diagnostic, category: 'map' }), { signal: options.signal, edgeMode: options.edgeMode });
@@ -1247,6 +1250,7 @@ function* validateMapSteps(index, options = {}) {
1247
1250
  passed: !sorted.some(({ code, severity }) => (severity === 'error' || severity === 'blocker') &&
1248
1251
  (code.startsWith('MAP_ADJACENCY') ||
1249
1252
  code.startsWith('MAP_IMPASSABLE') ||
1253
+ code.startsWith('MAP_STRAIT') ||
1250
1254
  code.startsWith('MAP_SEA_ADJACENCY') ||
1251
1255
  code.startsWith('MAP_SUPPLY') ||
1252
1256
  code.startsWith('MAP_RAILWAY'))),
@@ -12,13 +12,19 @@ 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';
22
28
  import { assignUnreachedRealmLand, resolveRealmRules } from './world-realms.js';
23
29
  // ---------------------------------------------------------------------------------------------
24
30
  // Deterministic randomness and noise
@@ -507,6 +513,48 @@ export function generateWorld(spec, context, options = {}) {
507
513
  let seamRepaired = geography !== undefined ? repairSeamCorners(surface.kind, width, height) : [];
508
514
  // A geography model gets its relief and rivers from the synthesis of ADR 0056; every other world
509
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
+ }
510
558
  const synthesized = geography === undefined
511
559
  ? undefined
512
560
  : synthesizeTerrain({
@@ -535,6 +583,26 @@ export function generateWorld(spec, context, options = {}) {
535
583
  }
536
584
  notes.push(terrainSummaryNote(synthesized, geography));
537
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
+ }
538
606
  const drainage = synthesized === undefined
539
607
  ? generateWorldDrainage({
540
608
  width,
@@ -628,6 +696,25 @@ export function generateWorld(spec, context, options = {}) {
628
696
  const countNote = provinceCountNote(count, featurePartition === undefined ? 'legacy' : 'feature-aware');
629
697
  if (countNote !== undefined)
630
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);
631
718
  const center = (id) => ({
632
719
  x: provinces.sumX[id] / provinces.size[id],
633
720
  y: provinces.sumY[id] / provinces.size[id],
@@ -834,7 +921,20 @@ export function generateWorld(spec, context, options = {}) {
834
921
  }
835
922
  return selected;
836
923
  };
837
- 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) => {
838
938
  const pixel = city.x >= 0 && city.y >= 0 && city.x < width && city.y < height
839
939
  ? Math.floor(city.y) * width + Math.floor(city.x)
840
940
  : undefined;
@@ -909,7 +1009,22 @@ export function generateWorld(spec, context, options = {}) {
909
1009
  .flatMap(([id]) => [...provinces.neighbors[id]]);
910
1010
  return [...provinces.neighbors[capital]];
911
1011
  });
912
- 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) {
913
1028
  let chosen = -1;
914
1029
  for (let index = 0; index < countryCount; index += 1) {
915
1030
  frontiers[index] = frontiers[index].filter((id) => provinces.kind[id] === LAND && !owner.has(id) && !blockedFor(index, id));
@@ -1015,12 +1130,33 @@ export function generateWorld(spec, context, options = {}) {
1015
1130
  resources: new Map(),
1016
1131
  victoryPoints: new Map(),
1017
1132
  navalBases: new Map(),
1133
+ forts: new Map(),
1018
1134
  };
1019
1135
  states.push(draft);
1020
1136
  for (const id of sorted)
1021
1137
  stateOf.set(id, draft);
1022
1138
  }
1023
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();
1024
1160
  // State values from size, terrain and development, with the country capital boosted.
1025
1161
  const categories = [
1026
1162
  'wasteland',
@@ -1048,112 +1184,153 @@ export function generateWorld(spec, context, options = {}) {
1048
1184
  state.capital = capital;
1049
1185
  countryCapitalState.set(spec.countries[index].tag, state);
1050
1186
  }
1051
- for (const state of states) {
1052
- const pixels = state.provinces.reduce((sum, id) => sum + provinces.size[id], 0);
1053
- const habitability = state.provinces.reduce((sum, id) => {
1054
- const value = {
1055
- plains: 1,
1056
- forest: 0.8,
1057
- hills: 0.6,
1058
- jungle: 0.5,
1059
- marsh: 0.4,
1060
- desert: 0.25,
1061
- mountain: 0.3,
1062
- urban: 1.5,
1063
- }[terrain.get(id) ?? 'plains'] ?? 0.5;
1064
- return sum + value * provinces.size[id];
1065
- }, 0) / pixels;
1066
- const isCapital = state.owner !== undefined && countryCapitalState.get(state.owner) === state;
1067
- const development = Math.min(1, habitability * (0.5 + random.next()) * (isCapital ? 1.8 : 1));
1068
- state.manpower = Math.max(1_000, Math.round((pixels * 180 * development) / 1_000) * 1_000);
1069
- const categoryIndex = Math.max(0, Math.min(categories.length - 1, Math.round(development * 5.2 + (isCapital ? 1 : 0))));
1070
- state.category = categories[categoryIndex];
1071
- if (context.stateCategories !== undefined && !context.stateCategories.has(state.category))
1072
- state.category = context.stateCategories.has('rural')
1073
- ? 'rural'
1074
- : ([...context.stateCategories][0] ?? 'rural');
1075
- const slots = slotsFor[state.category] ?? 2;
1076
- state.infrastructure = Math.max(1, Math.min(5, Math.round(1 + development * 3 + (isCapital ? 1 : 0))));
1077
- state.civilian = Math.round(slots * (0.25 + random.next() * 0.3));
1078
- state.military = Math.min(slots - state.civilian, Math.round(slots * random.next() * 0.3));
1079
- if (isCapital) {
1080
- terrain.set(state.capital, 'urban');
1081
- state.airBase = 2;
1082
- }
1083
- const coastalProvinces = state.provinces.filter(coastal);
1084
- if (coastalProvinces.length > 0 && (isCapital || random.next() < 0.35)) {
1085
- state.navalBases.set(coastalProvinces[0], isCapital ? 3 : 1);
1086
- if (slots - state.civilian - state.military > 0 && random.next() < 0.5)
1087
- state.dockyard = 1;
1088
- }
1089
- for (const id of state.provinces) {
1090
- const kindOfLand = terrain.get(id);
1091
- const roll = random.next();
1092
- const add = (resource, amount) => state.resources.set(resource, (state.resources.get(resource) ?? 0) + amount);
1093
- if (kindOfLand === 'desert' && roll < 0.3)
1094
- add('oil', 2 + random.int(6));
1095
- if ((kindOfLand === 'mountain' || kindOfLand === 'hills') && roll < 0.35)
1096
- add('steel', 2 + random.int(8));
1097
- if (kindOfLand === 'mountain' && roll < 0.15)
1098
- add('tungsten', 1 + random.int(4));
1099
- if (kindOfLand === 'hills' && roll > 0.85)
1100
- add('chromium', 1 + random.int(4));
1101
- if (kindOfLand === 'jungle' && roll < 0.35)
1102
- add('rubber', 1 + random.int(5));
1103
- if (kindOfLand === 'plains' && roll < 0.05)
1104
- add('aluminium', 1 + random.int(4));
1105
- }
1106
- state.victoryPoints.set(state.capital, isCapital ? 25 : Math.max(1, Math.round(development * 8)));
1107
- }
1108
1187
  const cityNames = new Map();
1109
- for (const city of namedCities) {
1110
- const state = stateOf.get(city.province);
1111
- cityNames.set(city.province, city.name);
1112
- state.victoryPoints.set(city.province, Math.max(5, state.victoryPoints.get(city.province) ?? 0));
1113
- terrain.set(city.province, 'urban');
1114
- }
1115
- // Each coastal territory needs a port even when its capital lies inland or on another island.
1116
- const portChecked = new Set();
1117
- for (const start of landIds) {
1118
- if (portChecked.has(start))
1119
- continue;
1120
- const queue = [start];
1121
- portChecked.add(start);
1122
- let bestCoast;
1123
- let hasPort = false;
1124
- for (const id of queue) {
1125
- if (stateOf.get(id).navalBases.has(id))
1126
- hasPort = true;
1127
- if (coastal(id) &&
1128
- (bestCoast === undefined || provinces.size[id] > provinces.size[bestCoast]))
1129
- bestCoast = id;
1130
- for (const next of provinces.neighbors[id])
1131
- if (!portChecked.has(next) &&
1132
- provinces.kind[next] === LAND &&
1133
- owner.get(next) === owner.get(start)) {
1134
- portChecked.add(next);
1135
- queue.push(next);
1136
- }
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
+ }
1137
1231
  }
1138
- if (!hasPort && bestCoast !== undefined)
1139
- 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);
1140
1244
  }
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}`);
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)));
1149
1302
  }
1150
- else if (label.kind === 'sea' || label.kind === 'region') {
1151
- const members = label.kind === 'sea' ? seaIds : landIds;
1152
- 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))
1153
1313
  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}`);
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);
1157
1334
  }
1158
1335
  }
1159
1336
  // Province IDs: 1-based in reading order of the province's first pixel; 0 is the null row.
@@ -1222,8 +1399,11 @@ export function generateWorld(spec, context, options = {}) {
1222
1399
  message: `The rim edge policy wrote ${rimRows.length} impassable adjacency row(s) for provinces that meet across the seam`,
1223
1400
  details: { rows: rimRows.length },
1224
1401
  });
1225
- 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`));
1226
- 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)));
1227
1407
  add(`${map}/positions.txt`, text(''));
1228
1408
  add(`${map}/ambient_object.txt`, text(spec.ambientFrame === true
1229
1409
  ? ambientObjectText(width, height)
@@ -1562,7 +1742,16 @@ export function generateWorld(spec, context, options = {}) {
1562
1742
  ...(state.military > 0 ? [`\t\t\tarms_factory = ${state.military}`] : []),
1563
1743
  ...(state.dockyard > 0 ? [`\t\t\tdockyard = ${state.dockyard}`] : []),
1564
1744
  ...(state.airBase > 0 ? [`\t\t\tair_base = ${state.airBase}`] : []),
1565
- ...[...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
+ }),
1566
1755
  ];
1567
1756
  add(`${context.statesRoot}/${state.id}-${slug(stateNames.get(state.id) ?? `STATE_${state.id}`).slice(0, 80)}.txt`, text([
1568
1757
  'state = {',
@@ -1764,6 +1953,9 @@ export function generateWorld(spec, context, options = {}) {
1764
1953
  .sort((a, b) => a - b)
1765
1954
  .map((region) => [`STRATEGICREGION_${region}`, regionNames.get(region) ?? `Region ${region}`]),
1766
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]),
1767
1959
  ...continentNames.map((name, index) => [`world_continent_${index + 1}`, name]),
1768
1960
  ...countryLocalisation,
1769
1961
  ...(spec.realms === undefined
@@ -1783,6 +1975,8 @@ export function generateWorld(spec, context, options = {}) {
1783
1975
  warnings: [...interpretationWarnings, ...drainage.warnings],
1784
1976
  notes,
1785
1977
  ...(synthesized === undefined ? {} : { terrain: synthesized }),
1978
+ ...(economy === undefined ? {} : { railCost: economy.railCost }),
1979
+ ...(gameplay === undefined ? {} : { gameplay: { crossings: gameplay.crossings, economy } }),
1786
1980
  summary: {
1787
1981
  width,
1788
1982
  height,
@@ -256,6 +256,9 @@ const detectors = {
256
256
  const findings = [];
257
257
  for (const { node, token } of names) {
258
258
  const name = token.slice(token.lastIndexOf('.') + 1);
259
+ // `constant:category.key` reads a script constant, not a scoped variable.
260
+ if (token.startsWith('constant:'))
261
+ continue;
259
262
  if (!temporary.has(name) || regular.has(name))
260
263
  continue;
261
264
  if (!/^(?:[A-Za-z_][\w]*|[a-z_]+:[\w]+)(?:\.[A-Za-z_][\w]*)+$/u.test(token))