hoi4-agent-tools 4.3.0 → 4.4.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 (110) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
  3. package/dist/hoi4_agent_tools/core/configuration.js +11 -0
  4. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  5. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  6. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  7. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  8. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  9. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  10. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  11. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  12. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  13. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  14. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  15. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  16. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  17. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  18. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  19. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  20. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  21. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  22. package/dist/hoi4_agent_tools/map/model.js +15 -2
  23. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  24. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  25. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  26. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  27. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  28. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  29. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  30. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  31. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  32. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  33. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  34. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  35. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  36. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  37. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  38. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  39. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  40. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  41. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  42. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  43. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  44. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  45. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  46. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  47. package/dist/hoi4_agent_tools/map/render.js +36 -0
  48. package/dist/hoi4_agent_tools/map/service.js +191 -16
  49. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  50. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  51. package/dist/hoi4_agent_tools/map/water.js +227 -0
  52. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  53. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  54. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  55. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  56. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  57. package/dist/hoi4_agent_tools/map/world.js +462 -124
  58. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  59. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  60. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  61. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  62. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  63. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  64. package/dist/hoi4_agent_tools/version.js +1 -1
  65. package/docs/configuration.md +17 -14
  66. package/docs/map.md +258 -15
  67. package/docs/tools.md +4 -4
  68. package/docs/troubleshooting.md +11 -11
  69. package/npm-shrinkwrap.json +109 -91
  70. package/package.json +3 -3
  71. package/schemas/configuration.schema.json +65 -58
  72. package/schemas/continuous-focus-palette.schema.json +1 -1
  73. package/schemas/custom-weighted-pool.schema.json +1 -1
  74. package/schemas/decision-inspect-input.schema.json +1 -1
  75. package/schemas/error-log-input.schema.json +1 -1
  76. package/schemas/event-feature-manifest.schema.json +1 -1
  77. package/schemas/focus-plan.schema.json +1 -1
  78. package/schemas/focus-planning-sidecar.schema.json +1 -1
  79. package/schemas/geography-model.schema.json +1148 -0
  80. package/schemas/gui-animation-source.schema.json +1 -1
  81. package/schemas/gui-helper.schema.json +5 -11
  82. package/schemas/gui-scenario.schema.json +1 -1
  83. package/schemas/helper-expansion-request.schema.json +1 -1
  84. package/schemas/helper-expansion-summary.schema.json +1 -1
  85. package/schemas/impact-inspect-input.schema.json +1 -1
  86. package/schemas/map-operation.schema.json +2156 -259
  87. package/schemas/mechanic-test-input.schema.json +1 -1
  88. package/schemas/mod-index-input.schema.json +1 -1
  89. package/schemas/operation-result.schema.json +1 -1
  90. package/schemas/package-check-input.schema.json +1 -1
  91. package/schemas/package-manifest.schema.json +1 -1
  92. package/schemas/probability-analysis-result.schema.json +25 -7
  93. package/schemas/probability-compare-input.schema.json +1 -1
  94. package/schemas/probability-evaluate-input.schema.json +1 -1
  95. package/schemas/probability-inspect-input.schema.json +1 -1
  96. package/schemas/probability-render-input.schema.json +1 -1
  97. package/schemas/probability-scenario-set.schema.json +1 -1
  98. package/schemas/probability-sequence-input.schema.json +1 -1
  99. package/schemas/probability-simulate-input.schema.json +1 -1
  100. package/schemas/probability-sweep-input.schema.json +1 -1
  101. package/schemas/reference-context-input.schema.json +1 -1
  102. package/schemas/reference-read-input.schema.json +1 -1
  103. package/schemas/reference-search-input.schema.json +1 -1
  104. package/schemas/reference-sheets.schema.json +267 -0
  105. package/schemas/scenario-suite.schema.json +1 -1
  106. package/schemas/scenario-test-input.schema.json +1 -1
  107. package/schemas/script-validate-input.schema.json +1 -1
  108. package/schemas/shared-scenario.schema.json +1 -1
  109. package/schemas/source-lookup-input.schema.json +1 -1
  110. package/server.json +2 -2
@@ -87,7 +87,11 @@ export function selectActiveFiles(files) {
87
87
  }
88
88
  export function parseTextDocument(file) {
89
89
  if (file.bytes.length > MAP_TEXT_MAX_BYTES) {
90
- throw new ServiceError('MAP_TEXT_FILE_LIMIT', 'Map text table exceeds the supported 16 MiB parsing limit', { bytes: file.bytes.length, maximumBytes: MAP_TEXT_MAX_BYTES });
90
+ throw new ServiceError('MAP_TEXT_FILE_LIMIT', `Map text table ${file.relativePath} exceeds the supported 16 MiB parsing limit (${file.bytes.length} bytes)`, {
91
+ relativePath: file.relativePath,
92
+ bytes: file.bytes.length,
93
+ maximumBytes: MAP_TEXT_MAX_BYTES,
94
+ });
91
95
  }
92
96
  const decoded = decodeSource(file.bytes);
93
97
  const lines = [];
@@ -101,7 +105,11 @@ export function parseTextDocument(file) {
101
105
  const text = full.replace(/(?:\r\n|\n|\r)$/u, '');
102
106
  const start = match.index;
103
107
  if (index >= MAP_TEXT_MAX_RECORDS) {
104
- throw new ServiceError('MAP_TEXT_RECORD_LIMIT', 'Map text table exceeds the supported record limit', { records: index + 1, maximumRecords: MAP_TEXT_MAX_RECORDS });
108
+ throw new ServiceError('MAP_TEXT_RECORD_LIMIT', `Map text table ${file.relativePath} exceeds the supported record limit`, {
109
+ relativePath: file.relativePath,
110
+ records: index + 1,
111
+ maximumRecords: MAP_TEXT_MAX_RECORDS,
112
+ });
105
113
  }
106
114
  lines.push({ index, start, end: start + text.length, fullEnd: start + full.length, text });
107
115
  index += 1;
@@ -1114,6 +1122,9 @@ export class MapWorkspaceIndex {
1114
1122
  railwayFile;
1115
1123
  /** The active heightmap, when the scan included it; decoded on first use. */
1116
1124
  heightmapFile;
1125
+ /** The active river and terrain bitmaps, when the scan included them (border quality). */
1126
+ riversFile;
1127
+ terrainBitmapFile;
1117
1128
  #heightmap;
1118
1129
  /**
1119
1130
  * Map height (the Y coordinate) at a bitmap pixel: the heightmap value divided by ten, as the
@@ -1297,6 +1308,8 @@ export class MapWorkspaceIndex {
1297
1308
  ? undefined
1298
1309
  : parseTerrainCategories(terrainFiles, this.diagnostics);
1299
1310
  this.heightmapFile = mapFile(this.activeFiles, defaultMapSelectorValue(this.defaultMapFile, 'heightmap', 'heightmap.bmp'), this.sourceRoots.map);
1311
+ this.riversFile = mapFile(this.activeFiles, defaultMapSelectorValue(this.defaultMapFile, 'rivers', 'rivers.bmp'), this.sourceRoots.map);
1312
+ this.terrainBitmapFile = mapFile(this.activeFiles, defaultMapSelectorValue(this.defaultMapFile, 'terrain', 'terrain.bmp'), this.sourceRoots.map);
1300
1313
  const buildingFiles = this.activeFiles.all.filter((file) => /^common\/buildings\/[^/]+\.txt$/iu.test(normalizedPath(file.relativePath)));
1301
1314
  const buildings = parseBuildingDefinitions(buildingFiles, []);
1302
1315
  this.buildingDefinitions = buildings.definitions;
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
3
- import { compareCodeUnits, deterministicId, sha256Bytes } from '../core/canonical.js';
3
+ import { canonicalJson, compareCodeUnits, deterministicId, sha256Bytes, } from '../core/canonical.js';
4
4
  import { ServiceError } from '../core/result.js';
5
5
  import { applyReplacements, assignments, encodeSource, parseLocalisation, parseClausewitz, } from '../core/source/index.js';
6
6
  import { rgbKey } from './bmp.js';
@@ -8,9 +8,12 @@ import { MAP_MASK_CELL_LIMIT, MAP_POLYGON_WORK_LIMIT, MAP_SELECTED_OFFSET_BYTES,
8
8
  import { derivedStateCapital, encodeTextDocument, LEVEL_STATE_BUILDINGS, MapWorkspaceIndex, parseMapDate, parseTextDocument, } from './model.js';
9
9
  import { planReferenceRemap, ReferenceRemapError } from './references.js';
10
10
  import { generateWorld } from './world.js';
11
+ import { resolveEdge } from './world-edge.js';
11
12
  import { planMapPositions, planSupplyRebuild } from './derived.js';
12
13
  import { buildWorldContext } from './world-context.js';
13
14
  import { reconcileWorldOutputs } from './world-outputs.js';
15
+ import { resolvePartitionMode } from './partition.js';
16
+ import { unsupportedGeographyParts } from './relief/index.js';
14
17
  function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
15
18
  if (iteration % stride !== 0)
16
19
  return;
@@ -3272,23 +3275,60 @@ function descriptorWithReplacePaths(index, roots) {
3272
3275
  const body = textValue.endsWith('\n') ? textValue : `${textValue}${newline}`;
3273
3276
  return Buffer.from(`${body}${missing.map((root) => `replace_path="${root}"`).join(newline)}${missing.length > 0 ? newline : ''}`, 'utf8');
3274
3277
  }
3275
- function applyCreateWorld(base, changes, operation, context) {
3278
+ function colormapPaletteOverrides(palette) {
3279
+ return Object.fromEntries(Object.entries(palette).flatMap(([name, color]) => color === undefined ? [] : [[name, [color.r, color.g, color.b]]]));
3280
+ }
3281
+ function applyCreateWorld(base, changes, operation, context, signal) {
3282
+ const geography = context.geographyModels?.get(operation.id);
3283
+ const hasGeography = operation.geography !== undefined || operation.geographyPath !== undefined;
3284
+ if (hasGeography) {
3285
+ if (geography === undefined)
3286
+ throw new ServiceError('MAP_GEOGRAPHY_NOT_PREPARED', 'Geography models must be prepared by the authorized workspace service');
3287
+ if (!geography.valid)
3288
+ throw new ServiceError('MAP_GEOGRAPHY_INVALID', 'The geography model has errors; fix them (see the MAP_GEOGRAPHY_* diagnostics and map-geography-report.json) before generating a world', { modelHash: geography.geography.hash });
3289
+ const pending = unsupportedGeographyParts(geography.geography);
3290
+ if (pending.length > 0)
3291
+ throw new ServiceError('MAP_GEOGRAPHY_SYNTHESIS_UNAVAILABLE', `The geography model is valid and its relief, rivers and lakes can be synthesized, but this version cannot yet use ${pending.join(', ')} (the climate and biome stage); remove them from the model to generate relief and rivers only, or use preview:true to review the model overlay and report`, {
3292
+ modelHash: geography.geography.hash,
3293
+ available: ['relief', 'rivers', 'lakes'],
3294
+ unavailable: pending,
3295
+ });
3296
+ }
3297
+ else {
3298
+ if (operation.relief !== undefined)
3299
+ throw new ServiceError('MAP_RELIEF_REQUIRES_GEOGRAPHY', 'relief shapes the terrain synthesised from a geography model; give geography or geographyPath, or remove relief');
3300
+ if (typeof operation.rivers === 'object')
3301
+ throw new ServiceError('MAP_RIVER_DENSITY_REQUIRES_GEOGRAPHY', 'rivers { density } sets the river network of a geography world; give geography or geographyPath, or use an integer count');
3302
+ }
3276
3303
  const reference = context.referenceWorlds?.get(operation.id);
3277
3304
  if (operation.reference !== undefined && reference === undefined)
3278
3305
  throw new ServiceError('MAP_REFERENCE_NOT_PREPARED', 'Reference worlds must be prepared by the authorized workspace service');
3279
3306
  const mapRoot = base.sourceRoots.map[0] ?? 'map';
3280
3307
  const startingScenario = operation.startingScenario ?? reference !== undefined;
3308
+ const worldWidth = operation.width ?? reference?.surface.width ?? 1_024;
3309
+ const worldHeight = operation.height ?? reference?.surface.height ?? 512;
3310
+ const edge = resolveEdge(operation.edge, worldWidth, worldHeight);
3311
+ const look = operation.look;
3312
+ const colormapProfile = operation.colormap?.profile ?? (look === undefined ? 'legacy' : 'calibrated');
3313
+ if (look === 'custom' && operation.colormap?.palette === undefined)
3314
+ throw new ServiceError('MAP_LOOK_CUSTOM_PALETTE_REQUIRED', 'look "custom" needs colormap.palette with at least one class colour');
3315
+ if (colormapProfile === 'legacy' && look !== undefined)
3316
+ throw new ServiceError('MAP_LOOK_REQUIRES_CALIBRATED_COLORMAP', 'look selects a calibrated palette; remove colormap.profile "legacy" or look');
3317
+ const partitionMode = resolvePartitionMode(operation.partition, hasGeography);
3318
+ if (partitionMode === 'legacy' && operation.partitionTuning !== undefined)
3319
+ throw new ServiceError('MAP_PARTITION_TUNING_REQUIRES_FEATURE_AWARE', 'partitionTuning applies to the feature-aware partition; set partition to "feature-aware" or remove it');
3281
3320
  const world = generateWorld({
3282
3321
  startingScenario,
3283
3322
  ...(operation.worldName === undefined ? {} : { worldName: operation.worldName }),
3284
- width: operation.width ?? reference?.surface.width ?? 1_024,
3285
- height: operation.height ?? reference?.surface.height ?? 512,
3323
+ width: worldWidth,
3324
+ height: worldHeight,
3286
3325
  seed: operation.seed ?? 1,
3287
3326
  landCoverage: operation.landCoverage ?? 0.35,
3288
3327
  continents: operation.continents ?? 3,
3289
3328
  ...(operation.landPolygons === undefined ? {} : { landPolygons: operation.landPolygons }),
3290
3329
  landProvincePixels: operation.landProvincePixels ?? 400,
3291
3330
  seaProvincePixels: operation.seaProvincePixels ?? 2_500,
3331
+ provinceSizesDefaulted: operation.landProvincePixels === undefined && operation.seaProvincePixels === undefined,
3292
3332
  provincesPerState: operation.provincesPerState ?? (reference === undefined ? 5 : 12),
3293
3333
  landProvincesPerRegion: operation.landProvincesPerRegion ?? (reference === undefined ? 25 : 60),
3294
3334
  seaProvincesPerRegion: operation.seaProvincesPerRegion ?? (reference === undefined ? 10 : 30),
@@ -3310,8 +3350,47 @@ function applyCreateWorld(base, changes, operation, context) {
3310
3350
  ? {}
3311
3351
  : { continentNames: operation.continentNames }),
3312
3352
  startDate: operation.startDate ?? '1936.1.1',
3313
- rivers: operation.rivers ?? (reference === undefined ? 8 : 80),
3314
- }, buildWorldContext(base));
3353
+ rivers: typeof operation.rivers === 'number' ? operation.rivers : reference === undefined ? 8 : 80,
3354
+ ...(geography === undefined
3355
+ ? {}
3356
+ : {
3357
+ geography: geography.geography,
3358
+ ...(operation.relief === undefined
3359
+ ? {}
3360
+ : {
3361
+ relief: {
3362
+ ...(operation.relief.scale === undefined
3363
+ ? {}
3364
+ : { scale: operation.relief.scale }),
3365
+ ...(operation.relief.detail === undefined
3366
+ ? {}
3367
+ : { detail: operation.relief.detail }),
3368
+ },
3369
+ }),
3370
+ ...(typeof operation.rivers === 'object'
3371
+ ? { riverDensity: operation.rivers.density }
3372
+ : typeof operation.rivers === 'number'
3373
+ ? { riverCount: operation.rivers }
3374
+ : {}),
3375
+ }),
3376
+ ...(edge.mode === 'none' ? {} : { edge }),
3377
+ ...(operation.ambientFrame === true ? { ambientFrame: true } : {}),
3378
+ colormapProfile,
3379
+ ...(partitionMode === 'legacy'
3380
+ ? {}
3381
+ : {
3382
+ partition: {
3383
+ mode: partitionMode,
3384
+ ...(operation.partitionTuning === undefined
3385
+ ? {}
3386
+ : { tuning: operation.partitionTuning }),
3387
+ },
3388
+ }),
3389
+ ...(look === undefined ? {} : { look }),
3390
+ ...(operation.colormap?.palette === undefined
3391
+ ? {}
3392
+ : { colormapPalette: colormapPaletteOverrides(operation.colormap.palette) }),
3393
+ }, buildWorldContext(base), { signal });
3315
3394
  const roots = operation.replaceBaseGame === false
3316
3395
  ? []
3317
3396
  : [...WORLD_REPLACED_ROOTS, ...(startingScenario ? STARTING_SCENARIO_REPLACED_ROOTS : [])];
@@ -3349,6 +3428,14 @@ function applyCreateWorld(base, changes, operation, context) {
3349
3428
  operationId: operation.id,
3350
3429
  message,
3351
3430
  })),
3431
+ ...world.notes.map((note) => ({
3432
+ code: note.code,
3433
+ severity: note.severity,
3434
+ category: 'map',
3435
+ operationId: operation.id,
3436
+ message: note.message,
3437
+ ...(note.details === undefined ? {} : { details: note.details }),
3438
+ })),
3352
3439
  ...(world.warnings.length === 0
3353
3440
  ? []
3354
3441
  : [
@@ -3456,6 +3543,22 @@ function applyEntityLocator(base, changes, operation) {
3456
3543
  ]);
3457
3544
  addChange(changes, locator.file.relativePath, output, operation.id, 'text/plain');
3458
3545
  }
3546
+ /**
3547
+ * The operation as the transaction manifest records it. An inline geography model can hold
3548
+ * megabytes; the manifest keeps its SHA-256 and size instead, which still bind the plan hash to
3549
+ * the exact model, and a model given by path is bound by the file's read dependency.
3550
+ */
3551
+ function manifestOperationData(operation) {
3552
+ const data = JSON.parse(JSON.stringify(operation));
3553
+ if (operation.kind === 'create_world' && operation.geography !== undefined) {
3554
+ const canonical = canonicalJson(operation.geography);
3555
+ data.geography = {
3556
+ sha256: sha256Bytes(canonical),
3557
+ bytes: Buffer.byteLength(canonical, 'utf8'),
3558
+ };
3559
+ }
3560
+ return data;
3561
+ }
3459
3562
  function* planMapOperationSteps(base, operations, signal, context = {}) {
3460
3563
  yield* cancellationCheckpoint(signal);
3461
3564
  const changes = new Map();
@@ -3480,7 +3583,7 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
3480
3583
  id: operation.id,
3481
3584
  kind: operation.kind,
3482
3585
  summary: operation.summary ?? operation.kind.replaceAll('_', ' '),
3483
- data: JSON.parse(JSON.stringify(operation)),
3586
+ data: manifestOperationData(operation),
3484
3587
  });
3485
3588
  const changesCheckpoint = new Map([...changes].map(([key, change]) => [
3486
3589
  key,
@@ -3494,7 +3597,7 @@ function* planMapOperationSteps(base, operations, signal, context = {}) {
3494
3597
  const boundsCheckpoint = expectedChangedBounds;
3495
3598
  try {
3496
3599
  if (operation.kind === 'create_world')
3497
- diagnostics.push(...applyCreateWorld(base, changes, operation, context));
3600
+ diagnostics.push(...applyCreateWorld(base, changes, operation, context, signal));
3498
3601
  else if (operation.kind === 'move_state_provinces')
3499
3602
  applyMoveStateProvinces(base, changes, operation);
3500
3603
  else if (operation.kind === 'update_state')
@@ -0,0 +1,398 @@
1
+ import { ServiceError } from '../core/result.js';
2
+ /**
3
+ * Inputs of the feature-aware partition (ADR 0058): one byte (or two) per pixel describing the
4
+ * natural lines and the character of the land, in one typed contract. The contract exists so the
5
+ * partition does not care where the numbers come from. `partitionFieldsFromBitmaps` computes a
6
+ * basic version from the height, terrain and river bitmaps a map already has; the generator fills
7
+ * them from its own rasters and, for a geography world, from the synthesised heights, the model
8
+ * (`partitionFieldsFromGeography` adds regions and wall ranges) and the lines of the synthesis
9
+ * (`partitionFieldsWithTerrainLines`). Only `+ - * /` and `sqrt` are used, so every field is
10
+ * identical on every platform.
11
+ */
12
+ /** Terrain classes the partition distinguishes; `water` marks pixels that are not land. */
13
+ export const PARTITION_TERRAIN = {
14
+ plains: 0,
15
+ farmland: 1,
16
+ forest: 2,
17
+ hills: 3,
18
+ mountain: 4,
19
+ desert: 5,
20
+ marsh: 6,
21
+ jungle: 7,
22
+ snow: 8,
23
+ urban: 9,
24
+ water: 255,
25
+ };
26
+ /** Pixel kinds, as the province generator already uses them. */
27
+ export const PARTITION_KIND = { sea: 0, land: 1, lake: 2 };
28
+ const MAP_PIXEL_LIMIT = 13_238_272;
29
+ function invalid(message, details) {
30
+ throw new ServiceError('MAP_PARTITION_FIELDS_INVALID', message, details);
31
+ }
32
+ /** Check the sizes, kinds and region table of a set of fields; throws `MAP_PARTITION_FIELDS_INVALID`. */
33
+ export function assertPartitionFields(fields) {
34
+ const { width, height } = fields;
35
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 16 || height < 16)
36
+ invalid('Partition dimensions must be integers of at least 16 pixels', { width, height });
37
+ const total = width * height;
38
+ if (!Number.isSafeInteger(total) || total > MAP_PIXEL_LIMIT)
39
+ invalid('Partition dimensions exceed the 13,238,272-pixel map limit', { width, height });
40
+ const planes = [
41
+ ['kind', fields.kind, Uint8Array],
42
+ ['riverDistance', fields.riverDistance, Uint8Array],
43
+ ['ridge', fields.ridge, Uint8Array],
44
+ ['slope', fields.slope, Uint8Array],
45
+ ['terrainClass', fields.terrainClass, Uint8Array],
46
+ ['regionId', fields.regionId, Uint16Array],
47
+ ['fertility', fields.fertility, Uint8Array],
48
+ ['barrier', fields.barrier, Uint8Array],
49
+ ];
50
+ for (const [name, plane, type] of planes) {
51
+ if (!(plane instanceof type))
52
+ invalid(`Partition field ${name} must be a ${type.name}`, {
53
+ field: name,
54
+ });
55
+ if (plane.length !== total)
56
+ invalid(`Partition field ${name} does not match the map dimensions`, {
57
+ field: name,
58
+ length: plane.length,
59
+ expected: total,
60
+ });
61
+ }
62
+ for (let p = 0; p < total; p++)
63
+ if (fields.kind[p] > 2)
64
+ invalid('Partition kind contains a value other than sea (0), land (1) or lake (2)', {
65
+ pixel: p,
66
+ value: fields.kind[p],
67
+ });
68
+ const ids = new Set();
69
+ for (const region of fields.regions ?? []) {
70
+ if (!Number.isInteger(region.id) || region.id < 1 || region.id > 65_535 || ids.has(region.id))
71
+ invalid('Partition region ids must be unique integers from 1 to 65,535', { id: region.id });
72
+ ids.add(region.id);
73
+ if (region.provinceAreaPx !== undefined &&
74
+ (!Number.isFinite(region.provinceAreaPx) || region.provinceAreaPx < 20))
75
+ invalid('A region province area must be at least 20 pixels', { id: region.id });
76
+ const per = region.provincesPerState;
77
+ if (per !== undefined &&
78
+ (per.some((value) => !Number.isInteger(value) || value < 1) ||
79
+ per[0] > per[1] ||
80
+ per[1] > per[2]))
81
+ invalid('A region provincesPerState must be three increasing positive integers', {
82
+ id: region.id,
83
+ });
84
+ }
85
+ if (ids.size > 0)
86
+ for (let p = 0; p < total; p++) {
87
+ const id = fields.regionId[p];
88
+ if (id !== 0 && !ids.has(id))
89
+ invalid('A pixel names a region that the region table does not list', { pixel: p, id });
90
+ }
91
+ }
92
+ // ---------------------------------------------------------------------------------------------
93
+ // Diagonal contacts
94
+ /**
95
+ * Remove the one mask pattern no partition can represent: a 2 x 2 block whose diagonals are two
96
+ * kinds (land and sea, say) so that each kind touches itself only at a corner. Four provinces
97
+ * would meet there ("X crossing"), and neither pair can be merged because the pieces are not
98
+ * four-connected. One pixel of the non-land kind becomes the other kind (land beats sea and lake, sea
99
+ * beats lake: the pixel of the weaker kind on the north-east or north-west corner changes),
100
+ * repeating until no block is left. Returns the changed pixel indices; `kind` is modified in
101
+ * place. The east-west seam wraps.
102
+ */
103
+ export function smoothDiagonalContacts(kind, width, height, maxPasses = 8) {
104
+ const changed = [];
105
+ for (let pass = 0; pass < maxPasses; pass++) {
106
+ let found = false;
107
+ for (let y = 0; y + 1 < height; y++) {
108
+ const row = y * width;
109
+ for (let x = 0; x < width; x++) {
110
+ const next = x + 1 < width ? x + 1 : 0;
111
+ const nw = row + x;
112
+ const ne = row + next;
113
+ const sw = nw + width;
114
+ const se = ne + width;
115
+ const a = kind[nw];
116
+ const b = kind[ne];
117
+ if (a === b || kind[se] !== a || kind[sw] !== b)
118
+ continue;
119
+ // The kind that survives: land over lake over sea; the other diagonal gives way.
120
+ const rank = (value) => value === PARTITION_KIND.land ? 2 : value === PARTITION_KIND.lake ? 0 : 1;
121
+ const keep = rank(a) >= rank(b) ? a : b;
122
+ const target = keep === a ? ne : nw;
123
+ kind[target] = keep;
124
+ changed.push(target);
125
+ found = true;
126
+ }
127
+ }
128
+ if (!found)
129
+ break;
130
+ }
131
+ return changed;
132
+ }
133
+ /**
134
+ * Province parts never join across the east-west seam, so a 2x2 window that straddles it holds
135
+ * four provinces whenever the kind changes vertically on both sides (a coast that crosses the
136
+ * seam). Make the east column (x = 0) agree with its upper pixel there, one pixel per crossing,
137
+ * and return the changed pixels.
138
+ */
139
+ export function repairSeamCorners(kind, width, height, maxPasses = 8) {
140
+ const changed = [];
141
+ if (width < 2)
142
+ return changed;
143
+ for (let pass = 0; pass < maxPasses; pass++) {
144
+ let found = false;
145
+ for (let y = 0; y + 1 < height; y++) {
146
+ const west = y * width + width - 1;
147
+ const east = y * width;
148
+ if (kind[west] === kind[west + width] || kind[east] === kind[east + width])
149
+ continue;
150
+ kind[east + width] = kind[east];
151
+ changed.push(east + width);
152
+ found = true;
153
+ }
154
+ if (!found)
155
+ break;
156
+ }
157
+ return changed;
158
+ }
159
+ // ---------------------------------------------------------------------------------------------
160
+ // Adapter from bitmaps
161
+ /**
162
+ * Terrain class of each graphical terrain index of the base game's `terrain.bmp`: 0 plains,
163
+ * 1 forest, 2 and 17 hills, 3 desert, 4 sparse forest, 5 farmland, 6, 16 and 20 mountain, 9 marsh,
164
+ * 13 urban, 15 ocean, 19 permanent snow, 21 and 22 jungle. Others are plains. A mod with other
165
+ * indices passes its own table (the border metric derives one from `common/terrain`).
166
+ */
167
+ export function defaultTerrainClassTable() {
168
+ const table = new Uint8Array(256).fill(PARTITION_TERRAIN.plains);
169
+ const set = (name, ...indices) => {
170
+ for (const index of indices)
171
+ table[index] = PARTITION_TERRAIN[name];
172
+ };
173
+ set('forest', 1, 4);
174
+ set('hills', 2, 17);
175
+ set('desert', 3);
176
+ set('farmland', 5);
177
+ set('mountain', 6, 16, 20);
178
+ set('marsh', 9);
179
+ set('urban', 13);
180
+ set('water', 15, 14);
181
+ set('snow', 19);
182
+ set('jungle', 21, 22);
183
+ return table;
184
+ }
185
+ /** Three-four chamfer distance in pixels (capped at 255) to the nearest pixel of `source`. */
186
+ export function chamferDistance(source, width, height) {
187
+ const cap = 255 * 3;
188
+ const distance = new Uint16Array(width * height);
189
+ let any = false;
190
+ for (let p = 0; p < distance.length; p++) {
191
+ if (source(p)) {
192
+ distance[p] = 0;
193
+ any = true;
194
+ }
195
+ else
196
+ distance[p] = cap;
197
+ }
198
+ const output = new Uint8Array(width * height);
199
+ if (!any) {
200
+ output.fill(255);
201
+ return output;
202
+ }
203
+ // The east-west seam wraps, so two sweeps of both directions reach every pixel.
204
+ for (let sweep = 0; sweep < 2; sweep++) {
205
+ for (let y = 0; y < height; y++) {
206
+ const row = y * width;
207
+ for (let x = 0; x < width; x++) {
208
+ const left = x === 0 ? width - 1 : x - 1;
209
+ const right = x === width - 1 ? 0 : x + 1;
210
+ let best = distance[row + x];
211
+ const west = distance[row + left] + 3;
212
+ if (west < best)
213
+ best = west;
214
+ if (y > 0) {
215
+ const above = row - width;
216
+ const n = distance[above + x] + 3;
217
+ const nw = distance[above + left] + 4;
218
+ const ne = distance[above + right] + 4;
219
+ if (n < best)
220
+ best = n;
221
+ if (nw < best)
222
+ best = nw;
223
+ if (ne < best)
224
+ best = ne;
225
+ }
226
+ distance[row + x] = best > cap ? cap : best;
227
+ }
228
+ }
229
+ for (let y = height - 1; y >= 0; y--) {
230
+ const row = y * width;
231
+ for (let x = width - 1; x >= 0; x--) {
232
+ const left = x === 0 ? width - 1 : x - 1;
233
+ const right = x === width - 1 ? 0 : x + 1;
234
+ let best = distance[row + x];
235
+ const east = distance[row + right] + 3;
236
+ if (east < best)
237
+ best = east;
238
+ if (y < height - 1) {
239
+ const below = row + width;
240
+ const s = distance[below + x] + 3;
241
+ const sw = distance[below + left] + 4;
242
+ const se = distance[below + right] + 4;
243
+ if (s < best)
244
+ best = s;
245
+ if (sw < best)
246
+ best = sw;
247
+ if (se < best)
248
+ best = se;
249
+ }
250
+ distance[row + x] = best > cap ? cap : best;
251
+ }
252
+ }
253
+ }
254
+ for (let p = 0; p < output.length; p++) {
255
+ const value = distance[p];
256
+ output[p] = value >= cap ? 255 : Math.min(254, Math.floor((value + 1) / 3));
257
+ }
258
+ return output;
259
+ }
260
+ /** Central-difference gradient magnitude times 16, clamped to 255; zero off land. */
261
+ export function slopeField(heights, kind, width, height) {
262
+ const output = new Uint8Array(width * height);
263
+ for (let y = 0; y < height; y++) {
264
+ const row = y * width;
265
+ const above = (y > 0 ? y - 1 : y) * width;
266
+ const below = (y < height - 1 ? y + 1 : y) * width;
267
+ for (let x = 0; x < width; x++) {
268
+ if (kind[row + x] !== PARTITION_KIND.land)
269
+ continue;
270
+ const left = x === 0 ? width - 1 : x - 1;
271
+ const right = x === width - 1 ? 0 : x + 1;
272
+ // The step down to the water is not a slope: a neighbour that is not land counts as level.
273
+ const here = heights[row + x];
274
+ const hl = kind[row + left] === PARTITION_KIND.land ? heights[row + left] : here;
275
+ const hr = kind[row + right] === PARTITION_KIND.land ? heights[row + right] : here;
276
+ const ha = kind[above + x] === PARTITION_KIND.land ? heights[above + x] : here;
277
+ const hb = kind[below + x] === PARTITION_KIND.land ? heights[below + x] : here;
278
+ const dx = hr - hl;
279
+ const dy = hb - ha;
280
+ // |gradient| = sqrt(dx^2 + dy^2) / 2 per pixel; times 16.
281
+ const value = Math.sqrt(dx * dx + dy * dy) * 8;
282
+ output[row + x] = value > 255 ? 255 : Math.round(value);
283
+ }
284
+ }
285
+ return output;
286
+ }
287
+ /**
288
+ * Ridge strength: how far a land pixel stands above the mean of its two neighbours three pixels
289
+ * away along the best of four axes, times 10. A crest line is a band a few pixels wide.
290
+ */
291
+ export function ridgeField(heights, kind, width, height, reach = 3) {
292
+ const output = new Uint8Array(width * height);
293
+ const axes = [
294
+ [1, 0],
295
+ [0, 1],
296
+ [1, 1],
297
+ [1, -1],
298
+ ];
299
+ for (let y = 0; y < height; y++) {
300
+ const row = y * width;
301
+ for (let x = 0; x < width; x++) {
302
+ const p = row + x;
303
+ if (kind[p] !== PARTITION_KIND.land)
304
+ continue;
305
+ const here = heights[p];
306
+ let best = 0;
307
+ for (const [ax, ay] of axes) {
308
+ const y0 = y - ay * reach;
309
+ const y1 = y + ay * reach;
310
+ if (y0 < 0 || y1 >= height || y0 >= height || y1 < 0)
311
+ continue;
312
+ const x0 = (((x - ax * reach) % width) + width) % width;
313
+ const x1 = (x + ax * reach) % width;
314
+ const p0 = y0 * width + x0;
315
+ const p1 = y1 * width + x1;
316
+ // Water at either end is no valley: it counts as level ground.
317
+ const h0 = kind[p0] === PARTITION_KIND.land ? heights[p0] : here;
318
+ const h1 = kind[p1] === PARTITION_KIND.land ? heights[p1] : here;
319
+ const lift = here - (h0 + h1) / 2;
320
+ if (lift > best)
321
+ best = lift;
322
+ }
323
+ const value = best * 10;
324
+ output[p] = value > 255 ? 255 : Math.round(value);
325
+ }
326
+ }
327
+ return output;
328
+ }
329
+ const FERTILITY_BASE = {
330
+ [PARTITION_TERRAIN.plains]: 150,
331
+ [PARTITION_TERRAIN.farmland]: 225,
332
+ [PARTITION_TERRAIN.forest]: 110,
333
+ [PARTITION_TERRAIN.hills]: 85,
334
+ [PARTITION_TERRAIN.mountain]: 20,
335
+ [PARTITION_TERRAIN.desert]: 25,
336
+ [PARTITION_TERRAIN.marsh]: 70,
337
+ [PARTITION_TERRAIN.jungle]: 95,
338
+ [PARTITION_TERRAIN.snow]: 5,
339
+ [PARTITION_TERRAIN.urban]: 140,
340
+ };
341
+ /**
342
+ * Fertility from terrain class, river proximity (valleys are rich), slope and optional moisture.
343
+ * The relief and climate stages replace it with their own field; the partition only reads bytes.
344
+ */
345
+ export function fertilityField(kind, terrainClass, riverDistance, slope, moisture) {
346
+ const output = new Uint8Array(kind.length);
347
+ for (let p = 0; p < kind.length; p++) {
348
+ if (kind[p] !== PARTITION_KIND.land)
349
+ continue;
350
+ let value = FERTILITY_BASE[terrainClass[p]] ?? 100;
351
+ const river = riverDistance[p];
352
+ if (river <= 14)
353
+ value += (14 - river) * 4;
354
+ value -= slope[p] * 0.5;
355
+ if (moisture !== undefined)
356
+ value += (moisture[p] - 128) * 0.25;
357
+ output[p] = value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
358
+ }
359
+ return output;
360
+ }
361
+ /** Compute the partition fields from the bitmaps a map already has. */
362
+ export function partitionFieldsFromBitmaps(input) {
363
+ const { width, height, kind } = input;
364
+ const total = width * height;
365
+ const riverMax = input.riverMaxIndex ?? 11;
366
+ const rivers = input.rivers;
367
+ const riverDistance = rivers === undefined
368
+ ? new Uint8Array(total).fill(255)
369
+ : chamferDistance((p) => rivers[p] <= riverMax && kind[p] === PARTITION_KIND.land, width, height);
370
+ const slope = input.heights === undefined
371
+ ? new Uint8Array(total)
372
+ : slopeField(input.heights, kind, width, height);
373
+ const ridge = input.heights === undefined
374
+ ? new Uint8Array(total)
375
+ : ridgeField(input.heights, kind, width, height);
376
+ const terrainClass = new Uint8Array(total).fill(PARTITION_TERRAIN.water);
377
+ const table = input.terrainClasses ?? defaultTerrainClassTable();
378
+ for (let p = 0; p < total; p++)
379
+ if (kind[p] === PARTITION_KIND.land)
380
+ terrainClass[p] =
381
+ input.terrain === undefined ? PARTITION_TERRAIN.plains : table[input.terrain[p]];
382
+ for (let p = 0; p < total; p++)
383
+ if (kind[p] === PARTITION_KIND.land && terrainClass[p] === PARTITION_TERRAIN.water)
384
+ terrainClass[p] = PARTITION_TERRAIN.plains;
385
+ return {
386
+ width,
387
+ height,
388
+ kind,
389
+ riverDistance,
390
+ ridge,
391
+ slope,
392
+ terrainClass,
393
+ regionId: input.regionId ?? new Uint16Array(total),
394
+ fertility: fertilityField(kind, terrainClass, riverDistance, slope, input.moisture),
395
+ barrier: input.barrier ?? new Uint8Array(total),
396
+ ...(input.regions === undefined ? {} : { regions: input.regions }),
397
+ };
398
+ }