hoi4-agent-tools 4.3.0 → 4.5.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 (119) hide show
  1. package/CHANGELOG.md +33 -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/managed-outputs.js +26 -6
  5. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  6. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  7. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  8. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  9. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  10. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  11. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  12. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  13. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  14. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  15. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  16. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  17. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  18. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  19. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  20. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  21. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  22. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  23. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  24. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  25. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  26. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  27. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  28. package/dist/hoi4_agent_tools/map/model.js +17 -3
  29. package/dist/hoi4_agent_tools/map/operations.js +284 -14
  30. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  31. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  32. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  33. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  34. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  35. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  36. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  37. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  38. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
  40. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  41. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  42. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  43. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  44. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  45. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  46. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  47. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  48. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  49. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  50. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  51. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  52. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  53. package/dist/hoi4_agent_tools/map/render.js +36 -0
  54. package/dist/hoi4_agent_tools/map/service.js +206 -16
  55. package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
  56. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  57. package/dist/hoi4_agent_tools/map/water.js +227 -0
  58. package/dist/hoi4_agent_tools/map/world-context.js +47 -1
  59. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  60. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  61. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  62. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  63. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  64. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  65. package/dist/hoi4_agent_tools/map/world.js +562 -163
  66. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  67. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  68. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  69. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  70. package/dist/hoi4_agent_tools/schemas/map.js +280 -15
  71. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  72. package/dist/hoi4_agent_tools/version.js +1 -1
  73. package/docs/configuration.md +17 -14
  74. package/docs/focus.md +6 -0
  75. package/docs/map.md +292 -15
  76. package/docs/tools.md +4 -4
  77. package/docs/troubleshooting.md +11 -11
  78. package/npm-shrinkwrap.json +109 -91
  79. package/package.json +3 -3
  80. package/schemas/configuration.schema.json +65 -58
  81. package/schemas/continuous-focus-palette.schema.json +1 -1
  82. package/schemas/custom-weighted-pool.schema.json +1 -1
  83. package/schemas/decision-inspect-input.schema.json +1 -1
  84. package/schemas/error-log-input.schema.json +1 -1
  85. package/schemas/event-feature-manifest.schema.json +1 -1
  86. package/schemas/focus-plan.schema.json +1 -1
  87. package/schemas/focus-planning-sidecar.schema.json +1 -1
  88. package/schemas/geography-model.schema.json +1148 -0
  89. package/schemas/gui-animation-source.schema.json +1 -1
  90. package/schemas/gui-helper.schema.json +5 -11
  91. package/schemas/gui-scenario.schema.json +1 -1
  92. package/schemas/helper-expansion-request.schema.json +1 -1
  93. package/schemas/helper-expansion-summary.schema.json +1 -1
  94. package/schemas/impact-inspect-input.schema.json +1 -1
  95. package/schemas/map-operation.schema.json +2325 -292
  96. package/schemas/mechanic-test-input.schema.json +1 -1
  97. package/schemas/mod-index-input.schema.json +1 -1
  98. package/schemas/operation-result.schema.json +1 -1
  99. package/schemas/package-check-input.schema.json +1 -1
  100. package/schemas/package-manifest.schema.json +1 -1
  101. package/schemas/probability-analysis-result.schema.json +25 -7
  102. package/schemas/probability-compare-input.schema.json +1 -1
  103. package/schemas/probability-evaluate-input.schema.json +1 -1
  104. package/schemas/probability-inspect-input.schema.json +1 -1
  105. package/schemas/probability-render-input.schema.json +1 -1
  106. package/schemas/probability-scenario-set.schema.json +1 -1
  107. package/schemas/probability-sequence-input.schema.json +1 -1
  108. package/schemas/probability-simulate-input.schema.json +1 -1
  109. package/schemas/probability-sweep-input.schema.json +1 -1
  110. package/schemas/reference-context-input.schema.json +1 -1
  111. package/schemas/reference-read-input.schema.json +1 -1
  112. package/schemas/reference-search-input.schema.json +1 -1
  113. package/schemas/reference-sheets.schema.json +267 -0
  114. package/schemas/scenario-suite.schema.json +1 -1
  115. package/schemas/scenario-test-input.schema.json +1 -1
  116. package/schemas/script-validate-input.schema.json +1 -1
  117. package/schemas/shared-scenario.schema.json +1 -1
  118. package/schemas/source-lookup-input.schema.json +1 -1
  119. package/server.json +2 -2
@@ -0,0 +1,27 @@
1
+ export * from './fields.js';
2
+ export * from './land.js';
3
+ export { DEFAULT_RELIEF, HEIGHT_CEILING, LAND_FLOOR, PEAK_BASE, PEAK_SPAN, RELIEF_SCALE_FACTOR, peakHeight, plateauHeight, synthesizeMacroRelief, } from './macro-relief.js';
4
+ export { DEFAULT_RIVER_DENSITY, MAXIMUM_RIVER_CLIMB, RIVER_PIXEL_CAP, synthesizeHydrology, } from './hydrology.js';
5
+ export { RIVER_DISTRIBUTARY_START_INDEX, RIVER_LAND_INDEX, RIVER_SOURCE_INDEX, RIVER_TRIBUTARY_END_INDEX, RIVER_WATER_INDEX, rasterizeRivers, } from './river-raster.js';
6
+ export { NORMAL_RESPONSE, synthesizeTerrain, } from './terrain-synthesis.js';
7
+ export { reliefStatistics, straightRiverShare } from './checks.js';
8
+ /**
9
+ * Parts of a geography model the synthesis cannot use yet: biomes, climate, fertility, ice and the
10
+ * terrain shares of regions belong to the climate and biome stage. Relief, rivers
11
+ * and lakes are synthesised; a model that declares anything listed here is blocked rather than
12
+ * generated with those parts silently ignored.
13
+ */
14
+ export function unsupportedGeographyParts(geography) {
15
+ const parts = [];
16
+ if (geography.biomes.length > 0)
17
+ parts.push(`${geography.biomes.length} biome feature(s)`);
18
+ if (geography.climate.declared)
19
+ parts.push('the climate');
20
+ if (geography.lands.some((land) => land.biome !== undefined || land.fertility !== undefined))
21
+ parts.push('land biome and fertility defaults');
22
+ if (geography.regions.some((region) => region.terrain !== undefined))
23
+ parts.push('region terrain shares');
24
+ if (geography.seas.some((sea) => sea.ice > 0))
25
+ parts.push('sea ice');
26
+ return parts;
27
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Land, sea and lake raster from the resolved geography model (ADR 0056). Lands and lakes are
3
+ * rasterised from their polygons with a seeded fractal displacement bounded by the land's
4
+ * `coastDetail`, so a coast gets bays and headlands the model's few vertices cannot hold. Land
5
+ * beats sea and a lake carves land, as `ResolvedGeography.surfaceAt` defines.
6
+ */
7
+ import { scanAreaStrip } from '../geography/index.js';
8
+ import { LAKE, LAND, SEA, labelComponents } from './grid.js';
9
+ import { clamp, fractal2 } from './math.js';
10
+ /** Displacement of a full-detail coast in map widths; scaled by the land's `coastDetail`. */
11
+ export const COAST_DISPLACEMENT = 0.005;
12
+ /** Lakes follow their outline closely: they use this share of the default displacement. */
13
+ const LAKE_DISPLACEMENT_SHARE = 0.3;
14
+ /** Land fragments below this size sink; enclosed sea below it fills (pixels). */
15
+ export const SPECK_PIXELS = 24;
16
+ function areaMask(area, grid) {
17
+ const x0 = Math.max(0, Math.floor(area.bounds.x0 * grid.width) - 1);
18
+ const y0 = Math.max(0, Math.floor(area.bounds.y0 * grid.height) - 1);
19
+ const x1 = Math.min(grid.width, Math.ceil(area.bounds.x1 * grid.width) + 1);
20
+ const y1 = Math.min(grid.height, Math.ceil(area.bounds.y1 * grid.height) + 1);
21
+ const width = Math.max(0, x1 - x0);
22
+ const height = Math.max(0, y1 - y0);
23
+ const mask = new Uint8Array(width * height);
24
+ if (width > 0 && height > 0)
25
+ scanAreaStrip(area, grid, { y0, y1 }, (y, xa, xb) => {
26
+ const row = (y - y0) * width - x0;
27
+ mask.fill(1, row + Math.max(xa, x0), row + Math.min(xb, x1));
28
+ });
29
+ return { x0, y0, width, height, mask };
30
+ }
31
+ /** Stamp `value` where the displaced sample of the area is inside it. */
32
+ function stamp(target, area, grid, value, amplitude, seed) {
33
+ const raster = areaMask(area, grid);
34
+ if (raster.width === 0 || raster.height === 0)
35
+ return;
36
+ const reach = Math.ceil(amplitude);
37
+ const xa = Math.max(0, raster.x0 - reach);
38
+ const ya = Math.max(0, raster.y0 - reach);
39
+ const xb = Math.min(grid.width, raster.x0 + raster.width + reach);
40
+ const yb = Math.min(grid.height, raster.y0 + raster.height + reach);
41
+ const wavelength = Math.max(24, 0.04 * grid.width);
42
+ for (let y = ya; y < yb; y += 1)
43
+ for (let x = xa; x < xb; x += 1) {
44
+ let sx = x;
45
+ let sy = y;
46
+ if (amplitude > 0) {
47
+ const dx = clamp((fractal2(seed + 501, x, y, wavelength, 5) - 0.5) * 2.6, -1, 1);
48
+ const dy = clamp((fractal2(seed + 503, x, y, wavelength, 5) - 0.5) * 2.6, -1, 1);
49
+ sx = Math.floor(x + 0.5 + dx * amplitude);
50
+ sy = Math.floor(y + 0.5 + dy * amplitude);
51
+ }
52
+ const mx = sx - raster.x0;
53
+ const my = sy - raster.y0;
54
+ if (mx < 0 || my < 0 || mx >= raster.width || my >= raster.height)
55
+ continue;
56
+ if (raster.mask[my * raster.width + mx] === 0)
57
+ continue;
58
+ const cell = y * grid.width + x;
59
+ target[cell] = value;
60
+ }
61
+ }
62
+ /** Sink land fragments and fill enclosed sea below `SPECK_PIXELS`; lakes are untouched. */
63
+ export function removeSpecks(kind, width, height) {
64
+ const lands = labelComponents(width, height, (index) => kind[index] === LAND);
65
+ for (let index = 0; index < kind.length; index += 1) {
66
+ const id = lands.label[index];
67
+ if (id >= 0 && lands.sizes[id] < SPECK_PIXELS)
68
+ kind[index] = SEA;
69
+ }
70
+ const seas = labelComponents(width, height, (index) => kind[index] === SEA);
71
+ const open = new Set();
72
+ for (let x = 0; x < width; x += 1) {
73
+ open.add(seas.label[x]);
74
+ open.add(seas.label[(height - 1) * width + x]);
75
+ }
76
+ for (let y = 0; y < height; y += 1) {
77
+ open.add(seas.label[y * width]);
78
+ open.add(seas.label[y * width + width - 1]);
79
+ }
80
+ for (let index = 0; index < kind.length; index += 1) {
81
+ const id = seas.label[index];
82
+ if (id >= 0 && !open.has(id) && seas.sizes[id] < SPECK_PIXELS)
83
+ kind[index] = LAND;
84
+ }
85
+ }
86
+ /**
87
+ * Rasterise lands and lakes of the model: 0 sea, 1 land, 2 lake. Seas need no raster: water is
88
+ * what no land claims.
89
+ */
90
+ export function rasterizeGeographyKind(geography, options) {
91
+ const grid = { width: options.width, height: options.height };
92
+ const kind = new Uint8Array(grid.width * grid.height);
93
+ for (const land of geography.lands) {
94
+ options.signal?.throwIfAborted();
95
+ const amplitude = land.coastDetail * COAST_DISPLACEMENT * grid.width;
96
+ stamp(kind, land.area, grid, LAND, amplitude, options.seed);
97
+ }
98
+ removeSpecks(kind, grid.width, grid.height);
99
+ overlayGeographyLakes(kind, geography, options);
100
+ return kind;
101
+ }
102
+ /** Lakes of the model carve whatever surface lies under them (a reference picture's land too). */
103
+ export function overlayGeographyLakes(kind, geography, options) {
104
+ const grid = { width: options.width, height: options.height };
105
+ const amplitude = 0.5 * COAST_DISPLACEMENT * LAKE_DISPLACEMENT_SHARE * grid.width;
106
+ for (const lake of geography.lakes) {
107
+ options.signal?.throwIfAborted();
108
+ stamp(kind, lake.area, grid, LAKE, amplitude, options.seed + 17);
109
+ }
110
+ }
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Macro relief of the geography model (ADR 0056): the smooth part of the height field on a grid of
3
+ * half the map size. Fractal base relief, plateaus with cliff or ramp edges, mountain ranges as
4
+ * ridge, massif, escarpment and wall profiles with asymmetry, passes, spurs and snow lines, and
5
+ * volcanoes with craters. Full-resolution detail, the coast ramp and the river carving are applied
6
+ * later, in strips.
7
+ *
8
+ * Heights are map values (0 to 255, the heightmap's own scale). Only `+ - * /`, `sqrt` and `floor`
9
+ * are used, so a seed gives the same terrain on every platform.
10
+ */
11
+ import { createBandStrip, clearBandStrip, fillAreaStrip, forEachPolylinePixel, rasterizeBandStrip, rowStrips, sampleAlong, scanAreaStrip, } from '../geography/index.js';
12
+ import { LAKE, LAND, bilinear, chamferDistance } from './grid.js';
13
+ import { Rng, clamp, clamp01, fractal2, noise2, pow125, pow15, ridged2, seedFromString, smoothstep, } from './math.js';
14
+ export const DEFAULT_RELIEF = { scale: 'vanilla', detail: 0.5 };
15
+ /** Height multipliers applied to everything above the lowland. */
16
+ export const RELIEF_SCALE_FACTOR = {
17
+ low: 0.75,
18
+ vanilla: 1,
19
+ dramatic: 1.15,
20
+ };
21
+ /** Lowest height of land: the coast stands at this value (ADR 0053). */
22
+ export const LAND_FLOOR = 96;
23
+ /** Height a peak of `0` would have; a peak of `1` stands `PEAK_SPAN` above it. */
24
+ export const PEAK_BASE = 100;
25
+ export const PEAK_SPAN = 148;
26
+ /** The highest value a heightmap pixel may hold in practice (255 is reserved). */
27
+ export const HEIGHT_CEILING = 252;
28
+ /** Map height of a crest or cone given by a 0..1 peak. */
29
+ export function peakHeight(peak, scale) {
30
+ return Math.min(HEIGHT_CEILING, PEAK_BASE + PEAK_SPAN * peak * scale);
31
+ }
32
+ /** Map height of a plateau given by a 0..1 relief; 0 is the land floor, a chasm bottom. */
33
+ export function plateauHeight(height, scale) {
34
+ return Math.min(HEIGHT_CEILING, LAND_FLOOR + PEAK_SPAN * height * scale);
35
+ }
36
+ /** Kind of each macro cell: the majority of its four pixels, with lake before land before sea. */
37
+ function downsampleKind(kind, width, height) {
38
+ const mw = width / 2;
39
+ const mh = height / 2;
40
+ const out = new Uint8Array(mw * mh);
41
+ for (let y = 0; y < mh; y += 1)
42
+ for (let x = 0; x < mw; x += 1) {
43
+ const a = kind[2 * y * width + 2 * x];
44
+ const b = kind[2 * y * width + 2 * x + 1];
45
+ const c = kind[(2 * y + 1) * width + 2 * x];
46
+ const d = kind[(2 * y + 1) * width + 2 * x + 1];
47
+ let lakes = 0;
48
+ let lands = 0;
49
+ if (a === LAKE)
50
+ lakes += 1;
51
+ else if (a === LAND)
52
+ lands += 1;
53
+ if (b === LAKE)
54
+ lakes += 1;
55
+ else if (b === LAND)
56
+ lands += 1;
57
+ if (c === LAKE)
58
+ lakes += 1;
59
+ else if (c === LAND)
60
+ lands += 1;
61
+ if (d === LAKE)
62
+ lakes += 1;
63
+ else if (d === LAND)
64
+ lands += 1;
65
+ out[y * mw + x] = lakes >= 2 ? LAKE : lands >= 2 ? LAND : lakes + lands >= 2 ? LAND : 0;
66
+ }
67
+ return out;
68
+ }
69
+ // ---------------------------------------------------------------------------------------------
70
+ // Base relief
71
+ function landElevationField(geography, mw, mh, fallback) {
72
+ const field = new Float32Array(mw * mh).fill(fallback);
73
+ for (const land of geography.lands)
74
+ fillAreaStrip(land.area, { width: mw, height: mh }, { y0: 0, y1: mh }, field, land.elevationBase);
75
+ return field;
76
+ }
77
+ function baseRelief(kind, elevation, mw, mh, seed) {
78
+ const water = new Uint8Array(kind.length);
79
+ for (let index = 0; index < kind.length; index += 1)
80
+ water[index] = kind[index] === LAND ? 0 : 1;
81
+ const distance = chamferDistance(water, mw, mh, 4096);
82
+ const base = new Float32Array(mw * mh);
83
+ for (let y = 0; y < mh; y += 1)
84
+ for (let x = 0; x < mw; x += 1) {
85
+ const index = y * mw + x;
86
+ if (kind[index] !== LAND)
87
+ continue;
88
+ const eb = elevation[index];
89
+ const inland = smoothstep(distance[index] / (0.06 * mw));
90
+ const rolling = (fractal2(seed + 11, x, y, 0.16 * mw, 5) - 0.5) * 2;
91
+ const hills = clamp((fractal2(seed + 23, x, y, 0.07 * mw, 4) - 0.55) * 3.2, 0, 1);
92
+ // Texture at the scale of valleys and ridges: it gives water something to follow.
93
+ const texture = (fractal2(seed + 37, x, y, 0.025 * mw, 5) - 0.5) * 2;
94
+ base[index] = Math.max(LAND_FLOOR + 1, 99 +
95
+ 38 * eb +
96
+ 5 * inland +
97
+ rolling * (5 + 14 * eb) +
98
+ hills * (10 + 30 * eb) +
99
+ texture * (4 + 8 * eb + 6 * hills) +
100
+ (ridged2(seed + 53, x, y, 0.045 * mw, 4) - 0.45) * 2 * (2.5 + 6 * eb));
101
+ }
102
+ return base;
103
+ }
104
+ // ---------------------------------------------------------------------------------------------
105
+ // Plateaus
106
+ function applyPlateau(plateau, heights, kind, mw, mh, scale, seed, footprint) {
107
+ const area = plateau.area;
108
+ const x0 = Math.max(0, Math.floor(area.bounds.x0 * mw) - 2);
109
+ const y0 = Math.max(0, Math.floor(area.bounds.y0 * mh) - 2);
110
+ const x1 = Math.min(mw, Math.ceil(area.bounds.x1 * mw) + 2);
111
+ const y1 = Math.min(mh, Math.ceil(area.bounds.y1 * mh) + 2);
112
+ const w = x1 - x0;
113
+ const h = y1 - y0;
114
+ if (w <= 0 || h <= 0)
115
+ return;
116
+ const inside = new Uint8Array(w * h);
117
+ scanAreaStrip(area, { width: mw, height: mh }, { y0, y1 }, (y, xa, xb) => {
118
+ const row = (y - y0) * w - x0;
119
+ inside.fill(1, row + Math.max(xa, x0), row + Math.min(xb, x1));
120
+ });
121
+ const outside = new Uint8Array(w * h);
122
+ let cells = 0;
123
+ for (let index = 0; index < inside.length; index += 1) {
124
+ outside[index] = inside[index] === 0 ? 1 : 0;
125
+ cells += inside[index];
126
+ }
127
+ if (cells === 0)
128
+ return;
129
+ const depth = chamferDistance(outside, w, h, 4096);
130
+ const edgeCells = plateau.edge === 'cliff' ? 2 : clamp(0.3 * Math.sqrt(cells), 6, 60);
131
+ const top = plateauHeight(plateau.height, scale);
132
+ const plateauSeed = seedFromString(seed, plateau.id);
133
+ for (let y = 0; y < h; y += 1)
134
+ for (let x = 0; x < w; x += 1) {
135
+ const local = y * w + x;
136
+ if (inside[local] === 0)
137
+ continue;
138
+ const index = (y + y0) * mw + x + x0;
139
+ if (kind[index] !== LAND)
140
+ continue;
141
+ const weight = smoothstep((depth[local] - 0.5) / edgeCells);
142
+ const target = top + (fractal2(plateauSeed, x + x0, y + y0, 0.03 * mw, 3) - 0.5) * 3;
143
+ const next = heights[index] + (Math.max(LAND_FLOOR, target) - heights[index]) * weight;
144
+ heights[index] = next;
145
+ footprint[index] = Math.max(footprint[index], 3.2 * weight * (1 - weight));
146
+ }
147
+ }
148
+ // ---------------------------------------------------------------------------------------------
149
+ // Ranges
150
+ const SPUR_ANGLES = [
151
+ [0.6427876, 0.7660444],
152
+ [0.5735764, 0.819152],
153
+ [0.5, 0.8660254],
154
+ [0.4226183, 0.9063078],
155
+ [0.3420201, 0.9396926],
156
+ ];
157
+ /** Pass window half-length in map widths: a saddle is wider than the gap through it. */
158
+ function passHalfLength(width) {
159
+ return Math.max(width * 2.2, 0.004);
160
+ }
161
+ function buildRangeTable(range, geography, base, mw, mh, scale) {
162
+ const path = range.path;
163
+ const count = clamp(Math.ceil(path.length * mw * 0.5), 32, 4096);
164
+ const crest = new Float32Array(count);
165
+ const halfWidth = new Float32Array(count);
166
+ const baseAlong = new Float32Array(count);
167
+ const envelope = new Float32Array(count).fill(1);
168
+ const raw = new Float32Array(count);
169
+ for (let k = 0; k < count; k += 1) {
170
+ const sample = sampleAlong(path, geography.frame, k / (count - 1));
171
+ crest[k] = peakHeight(sample.peak, scale);
172
+ halfWidth[k] = sample.halfWidth;
173
+ raw[k] = bilinear(base, mw, mh, sample.x * mw - 0.5, sample.y * mh - 0.5);
174
+ }
175
+ // Smooth the base under the crest so lumps in the base noise do not ripple the crest line.
176
+ const reach = Math.max(2, Math.round(count / 24));
177
+ for (let k = 0; k < count; k += 1) {
178
+ let sum = 0;
179
+ let n = 0;
180
+ for (let j = Math.max(0, k - reach); j <= Math.min(count - 1, k + reach); j += 1) {
181
+ sum += raw[j];
182
+ n += 1;
183
+ }
184
+ baseAlong[k] = sum / n;
185
+ }
186
+ const passes = [];
187
+ for (const id of range.passIds) {
188
+ const pass = geography.get('pass', id);
189
+ if (pass === undefined)
190
+ continue;
191
+ const halfLength = passHalfLength(pass.width);
192
+ passes.push({ id, at: pass.at, halfLength, depth: pass.depth });
193
+ for (let k = 0; k < count; k += 1) {
194
+ const distance = (k / (count - 1) - pass.at) * path.length;
195
+ const s = (distance * distance) / (halfLength * halfLength);
196
+ if (s >= 1)
197
+ continue;
198
+ const bump = (1 - s) * (1 - s);
199
+ envelope[k] = envelope[k] * (1 - pass.depth * bump);
200
+ }
201
+ }
202
+ return {
203
+ id: range.id,
204
+ count,
205
+ length: path.length,
206
+ crest,
207
+ halfWidth,
208
+ base: baseAlong,
209
+ envelope,
210
+ passes,
211
+ };
212
+ }
213
+ /** Cross profile of a range at normalised distance `u` (0 on the crest, 1 at the foot). */
214
+ function rangeProfile(profile, u, steep) {
215
+ if (u >= 1)
216
+ return 0;
217
+ switch (profile) {
218
+ case 'ridge': {
219
+ // A sharp crest over a wide apron of foothills.
220
+ return 0.65 * pow125(1 - u) + 0.35 * pow125(1 - pow15(u));
221
+ }
222
+ case 'massif': {
223
+ const f = 1 - u * u * u;
224
+ return pow125(f);
225
+ }
226
+ case 'wall': {
227
+ const u2 = u * u;
228
+ return 1 - u2 * u2 * u2;
229
+ }
230
+ case 'escarpment': {
231
+ if (steep) {
232
+ const u2 = u * u;
233
+ return 1 - u2 * u2;
234
+ }
235
+ return pow125(1 - pow125(u));
236
+ }
237
+ }
238
+ }
239
+ function table(values, count, t) {
240
+ const position = clamp(t, 0, 1) * (count - 1);
241
+ const k0 = Math.floor(position);
242
+ const k1 = k0 + 1 < count ? k0 + 1 : k0;
243
+ return values[k0] + (values[k1] - values[k0]) * (position - k0);
244
+ }
245
+ /**
246
+ * The share of the half-width by which the crest line may wander off the drawn path. Checks that
247
+ * look for a crest search this far either side of the path.
248
+ */
249
+ export const CREST_WANDER_SHARE = 0.35;
250
+ function applyRange(range, tableOf, geography, kind, mw, mh, scale, seed, targets) {
251
+ const path = range.path;
252
+ const grid = { width: mw, height: mh };
253
+ let widest = 0;
254
+ for (const half of path.halfWidth)
255
+ widest = Math.max(widest, half);
256
+ const widestCells = widest * mw;
257
+ const reach = widestCells * 2.9;
258
+ const snowHeight = range.snowline === undefined
259
+ ? 255
260
+ : Math.min(254, Math.round(peakHeight(range.snowline, scale)));
261
+ const rangeSeed = seedFromString(seed, `range:${range.id}`);
262
+ const asym = range.asymmetry;
263
+ const steepSide = asym >= 0 ? 1 : -1;
264
+ const margin = Math.ceil(reach + widestCells * CREST_WANDER_SHARE + 3);
265
+ const x0 = Math.max(0, Math.floor(path.bounds.x0 * mw - margin));
266
+ const y0 = Math.max(0, Math.floor(path.bounds.y0 * mh - margin));
267
+ const x1 = Math.min(mw, Math.ceil(path.bounds.x1 * mw + margin));
268
+ const y1 = Math.min(mh, Math.ceil(path.bounds.y1 * mh + margin));
269
+ if (x1 <= x0 || y1 <= y0)
270
+ return;
271
+ const local = {
272
+ x0,
273
+ y0,
274
+ width: x1 - x0,
275
+ height: y1 - y0,
276
+ delta: new Float32Array((x1 - x0) * (y1 - y0)),
277
+ flag: new Uint8Array((x1 - x0) * (y1 - y0)),
278
+ };
279
+ const stripHeight = 64;
280
+ const band = createBandStrip(grid, { y0: 0, y1: stripHeight });
281
+ for (const strip of rowStrips(grid, stripHeight)) {
282
+ if (strip.y1 <= y0 || strip.y0 >= y1)
283
+ continue;
284
+ const active = { ...band, strip };
285
+ clearBandStrip(active);
286
+ rasterizeBandStrip(path, grid, strip, reach, active);
287
+ for (let y = Math.max(strip.y0, y0); y < Math.min(strip.y1, y1); y += 1)
288
+ for (let x = x0; x < x1; x += 1) {
289
+ const cell = (y - strip.y0) * mw + x;
290
+ const d = active.distance[cell];
291
+ if (d === Infinity)
292
+ continue;
293
+ const t = active.t[cell];
294
+ const halfCells = table(tableOf.halfWidth, tableOf.count, t) * mw;
295
+ const side = active.side[cell];
296
+ let hw = halfCells;
297
+ let steep = false;
298
+ if (range.profile === 'escarpment') {
299
+ steep = side === 0 || side === steepSide;
300
+ hw = steep ? halfCells * 0.45 : halfCells * 1.1;
301
+ }
302
+ else if (asym !== 0)
303
+ hw = halfCells * clamp(1 + 0.6 * asym * side, 0.25, 2);
304
+ // The footprint wanders in width along the range and the crest keeps its height; walls
305
+ // stay straight.
306
+ const wander = range.profile === 'wall'
307
+ ? 0
308
+ : (fractal2(rangeSeed, x, y, Math.max(8, hw * 2.2), 3) - 0.5) * 1.5;
309
+ const u = (d * (1 + clamp(wander, -0.4, 0.4))) / hw;
310
+ if (u >= 1)
311
+ continue;
312
+ const f = rangeProfile(range.profile, u, steep);
313
+ if (f <= 0)
314
+ continue;
315
+ const envelope = table(tableOf.envelope, tableOf.count, t);
316
+ const crest = table(tableOf.crest, tableOf.count, t);
317
+ const baseUnder = table(tableOf.base, tableOf.count, t);
318
+ // Peaks and cols along the crest: the crest stays within 7% of the requested height.
319
+ const lump = clamp((fractal2(rangeSeed + 5, x, y, Math.max(8, hw * 1.6), 3) - 0.3) * 2.5, 0, 1);
320
+ const amplitude = Math.max(0, crest - baseUnder) * (0.93 + 0.07 * lump);
321
+ const delta = amplitude * f * envelope;
322
+ const at = (y - y0) * local.width + (x - x0);
323
+ if (delta > local.delta[at])
324
+ local.delta[at] = delta;
325
+ if (range.barrier !== 'none' && f * envelope >= 0.5)
326
+ local.flag[at] = 1;
327
+ }
328
+ }
329
+ // Spurs: short side ridges off the crest, into the same local window.
330
+ if (range.spurs > 0 && range.profile !== 'wall')
331
+ addSpurs(range, tableOf, geography, local, mw, mh, seed);
332
+ mergeWarped(local, targets, kind, mw, mh, {
333
+ seed: rangeSeed,
334
+ snowHeight,
335
+ barrier: range.barrier === 'impassable' ? 1 : range.barrier === 'fort-line' ? 2 : 0,
336
+ }, range.profile === 'wall' ? 0.08 : CREST_WANDER_SHARE * 0.85 * widestCells, widestCells);
337
+ forEachPolylinePixel(path, grid, { y0: 0, y1: mh }, (x, y) => {
338
+ targets.crest[y * mw + x] = 1;
339
+ });
340
+ }
341
+ /** Merge a range window into the targets, displacing it by a smooth noise so the crest meanders. */
342
+ function mergeWarped(local, targets, kind, mw, mh, options, amplitude, widestCells) {
343
+ const { seed: rangeSeed, snowHeight } = options;
344
+ const wavelength = Math.max(10, widestCells * 3);
345
+ for (let y = local.y0; y < local.y0 + local.height; y += 1)
346
+ for (let x = local.x0; x < local.x0 + local.width; x += 1) {
347
+ const index = y * mw + x;
348
+ if (kind[index] !== LAND)
349
+ continue;
350
+ let sx = x;
351
+ let sy = y;
352
+ if (amplitude > 0) {
353
+ sx =
354
+ x + clamp((fractal2(rangeSeed + 31, x, y, wavelength, 3) - 0.5) * 2.4, -1, 1) * amplitude;
355
+ sy =
356
+ y + clamp((fractal2(rangeSeed + 37, x, y, wavelength, 3) - 0.5) * 2.4, -1, 1) * amplitude;
357
+ }
358
+ const lx = sx - local.x0;
359
+ const ly = sy - local.y0;
360
+ if (lx < 0 || ly < 0 || lx > local.width - 1 || ly > local.height - 1)
361
+ continue;
362
+ const delta = bilinear(local.delta, local.width, local.height, lx, ly);
363
+ if (options.barrier > 0 && local.flag[Math.round(ly) * local.width + Math.round(lx)] === 1)
364
+ targets.barrier[index] = Math.max(targets.barrier[index], options.barrier);
365
+ if (delta <= 0.01)
366
+ continue;
367
+ if (delta > targets.delta[index])
368
+ targets.delta[index] = delta;
369
+ const weight = Math.min(1, delta / 50);
370
+ if (weight > targets.footprint[index])
371
+ targets.footprint[index] = weight;
372
+ if (snowHeight < 255 && delta > 4 && snowHeight < targets.snow[index])
373
+ targets.snow[index] = snowHeight;
374
+ }
375
+ }
376
+ function addSpurs(range, tableOf, geography, local, mw, mh, seed) {
377
+ const path = range.path;
378
+ const rng = new Rng(seedFromString(seed, `spur:${range.id}`));
379
+ const lengthCells = path.length * mw;
380
+ let along = 0;
381
+ while (along < lengthCells) {
382
+ const t = along / lengthCells;
383
+ const sample = sampleAlong(path, geography.frame, t);
384
+ const hw = sample.halfWidth * mw;
385
+ along += Math.max(2, (0.6 + 0.4 * rng.next()) * hw);
386
+ if (rng.next() > Math.min(1, range.spurs * 1.6))
387
+ continue;
388
+ const envelope = table(tableOf.envelope, tableOf.count, t);
389
+ if (envelope < 0.5)
390
+ continue;
391
+ const sign = rng.next() < 0.5 ? 1 : -1;
392
+ const angle = SPUR_ANGLES[Math.floor(rng.next() * SPUR_ANGLES.length) % SPUR_ANGLES.length];
393
+ const tx = sample.tangentX;
394
+ const ty = sample.tangentY;
395
+ const dx = tx * angle[0] - ty * angle[1] * sign;
396
+ const dy = tx * angle[1] * sign + ty * angle[0];
397
+ const length = (0.5 + 0.3 * rng.next()) * hw;
398
+ const px = sample.x * mw;
399
+ const py = sample.y * mh;
400
+ const ex = px + dx * length;
401
+ const ey = py + dy * length;
402
+ const spurHalf = Math.max(1.5, 0.2 * hw);
403
+ const crest = table(tableOf.crest, tableOf.count, t);
404
+ const amplitude = Math.max(0, crest - table(tableOf.base, tableOf.count, t));
405
+ const height = 0.55 * amplitude * envelope;
406
+ const minX = Math.max(local.x0, Math.floor(Math.min(px, ex) - spurHalf - 1));
407
+ const maxX = Math.min(local.x0 + local.width - 1, Math.ceil(Math.max(px, ex) + spurHalf + 1));
408
+ const minY = Math.max(local.y0, Math.floor(Math.min(py, ey) - spurHalf - 1));
409
+ const maxY = Math.min(local.y0 + local.height - 1, Math.ceil(Math.max(py, ey) + spurHalf + 1));
410
+ const sx = ex - px;
411
+ const sy = ey - py;
412
+ const lengthSquared = sx * sx + sy * sy;
413
+ for (let y = minY; y <= maxY; y += 1)
414
+ for (let x = minX; x <= maxX; x += 1) {
415
+ const qx = x + 0.5 - px;
416
+ const qy = y + 0.5 - py;
417
+ const a = lengthSquared === 0 ? 0 : clamp01((qx * sx + qy * sy) / lengthSquared);
418
+ const ox = qx - a * sx;
419
+ const oy = qy - a * sy;
420
+ const u = Math.sqrt(ox * ox + oy * oy) / spurHalf;
421
+ if (u >= 1)
422
+ continue;
423
+ const delta = height * pow125(1 - pow15(u)) * (1 - 0.65 * a);
424
+ const at = (y - local.y0) * local.width + (x - local.x0);
425
+ if (delta > local.delta[at])
426
+ local.delta[at] = delta;
427
+ }
428
+ }
429
+ }
430
+ // ---------------------------------------------------------------------------------------------
431
+ // Volcanoes
432
+ function applyVolcano(volcano, base, kind, mw, mh, scale, seed, targets) {
433
+ const radius = volcano.radius * mw;
434
+ const crater = Math.min(volcano.crater * mw, radius * 0.9);
435
+ const cx = volcano.at.x * mw;
436
+ const cy = volcano.at.y * mh;
437
+ const level = bilinear(base, mw, mh, cx - 0.5, cy - 0.5);
438
+ const amplitude = Math.max(0, peakHeight(volcano.peak, scale) - level);
439
+ const depth = Math.max(8, 0.22 * amplitude);
440
+ const volcanoSeed = seedFromString(seed, `volcano:${volcano.id}`);
441
+ const reach = radius * 1.25;
442
+ const minX = Math.max(0, Math.floor(cx - reach - 1));
443
+ const maxX = Math.min(mw - 1, Math.ceil(cx + reach));
444
+ const minY = Math.max(0, Math.floor(cy - reach - 1));
445
+ const maxY = Math.min(mh - 1, Math.ceil(cy + reach));
446
+ for (let y = minY; y <= maxY; y += 1)
447
+ for (let x = minX; x <= maxX; x += 1) {
448
+ const index = y * mw + x;
449
+ if (kind[index] !== LAND)
450
+ continue;
451
+ const dx = x + 0.5 - cx;
452
+ const dy = y + 0.5 - cy;
453
+ const r = Math.sqrt(dx * dx + dy * dy);
454
+ let delta;
455
+ if (r <= crater) {
456
+ const s = r / crater;
457
+ delta = amplitude - depth * (1 - s * s);
458
+ }
459
+ else {
460
+ // Direction on the unit circle keeps the noise continuous all the way round.
461
+ const ux = dx / r;
462
+ const uy = dy / r;
463
+ const outer = radius * (0.88 + 0.24 * noise2(volcanoSeed, ux * 3 + 11, uy * 3 + 11));
464
+ if (r >= outer)
465
+ continue;
466
+ const s = (r - crater) / (outer - crater);
467
+ const w = 1 - Math.sqrt(s);
468
+ const cone = w * Math.sqrt(Math.sqrt(w));
469
+ const n = noise2(volcanoSeed + 7, ux * 4 + 23, uy * 4 + 23) * 2 - 1;
470
+ const gully = (1 - (n < 0 ? -n : n)) * smoothstep(s * 3);
471
+ delta = amplitude * cone * (1 - 0.28 * gully * gully);
472
+ }
473
+ if (delta > targets.delta[index])
474
+ targets.delta[index] = delta;
475
+ const weight = Math.min(1, delta / 50);
476
+ if (weight > targets.footprint[index])
477
+ targets.footprint[index] = weight;
478
+ }
479
+ }
480
+ // ---------------------------------------------------------------------------------------------
481
+ /** Build the macro relief of the model. */
482
+ export function synthesizeMacroRelief(input) {
483
+ const { geography, width, height, seed } = input;
484
+ const mw = width / 2;
485
+ const mh = height / 2;
486
+ const scale = RELIEF_SCALE_FACTOR[input.relief.scale];
487
+ const kind = downsampleKind(input.kind, width, height);
488
+ const elevation = landElevationField(geography, mw, mh, 0.15);
489
+ const base = baseRelief(kind, elevation, mw, mh, seed);
490
+ const heights = Float32Array.from(base);
491
+ const footprint = new Float32Array(mw * mh);
492
+ for (const plateau of geography.plateaus) {
493
+ input.signal?.throwIfAborted();
494
+ applyPlateau(plateau, heights, kind, mw, mh, scale, seed, footprint);
495
+ }
496
+ const targets = {
497
+ delta: new Float32Array(mw * mh),
498
+ footprint: new Float32Array(mw * mh),
499
+ snow: new Uint8Array(mw * mh).fill(255),
500
+ crest: new Uint8Array(mw * mh),
501
+ barrier: new Uint8Array(mw * mh),
502
+ };
503
+ const ranges = [];
504
+ for (const range of geography.ranges) {
505
+ input.signal?.throwIfAborted();
506
+ const tableOf = buildRangeTable(range, geography, heights, mw, mh, scale);
507
+ ranges.push(tableOf);
508
+ applyRange(range, tableOf, geography, kind, mw, mh, scale, seed, targets);
509
+ }
510
+ for (const volcano of geography.volcanoes) {
511
+ input.signal?.throwIfAborted();
512
+ applyVolcano(volcano, heights, kind, mw, mh, scale, seed, targets);
513
+ }
514
+ for (let index = 0; index < heights.length; index += 1) {
515
+ if (kind[index] !== LAND) {
516
+ heights[index] = 0;
517
+ continue;
518
+ }
519
+ heights[index] = Math.min(HEIGHT_CEILING, heights[index] + targets.delta[index]);
520
+ footprint[index] = Math.max(footprint[index], targets.footprint[index]);
521
+ }
522
+ return {
523
+ width: mw,
524
+ height: mh,
525
+ kind,
526
+ base,
527
+ heights,
528
+ footprint,
529
+ snowLine: targets.snow,
530
+ crest: targets.crest,
531
+ barrier: targets.barrier,
532
+ ranges,
533
+ scale,
534
+ };
535
+ }