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.
- package/CHANGELOG.md +33 -1
- package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
- package/dist/hoi4_agent_tools/core/configuration.js +11 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
- package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
- package/dist/hoi4_agent_tools/core/transactions.js +23 -4
- package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
- package/dist/hoi4_agent_tools/core/workspace.js +42 -6
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
- package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
- package/dist/hoi4_agent_tools/map/bmp.js +9 -1
- package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
- package/dist/hoi4_agent_tools/map/colormap.js +128 -0
- package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
- package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
- package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
- package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
- package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
- package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
- package/dist/hoi4_agent_tools/map/model.js +17 -3
- package/dist/hoi4_agent_tools/map/operations.js +284 -14
- package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
- package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
- package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
- package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
- package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
- package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
- package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
- package/dist/hoi4_agent_tools/map/partition.js +163 -0
- package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
- package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
- package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
- package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
- package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
- package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
- package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
- package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
- package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
- package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
- package/dist/hoi4_agent_tools/map/render.js +36 -0
- package/dist/hoi4_agent_tools/map/service.js +206 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
- package/dist/hoi4_agent_tools/map/validation.js +9 -0
- package/dist/hoi4_agent_tools/map/water.js +227 -0
- package/dist/hoi4_agent_tools/map/world-context.js +47 -1
- package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
- package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +562 -163
- package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
- package/dist/hoi4_agent_tools/reference/operations.js +3 -2
- package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
- package/dist/hoi4_agent_tools/schemas/map.js +280 -15
- package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/configuration.md +17 -14
- package/docs/focus.md +6 -0
- package/docs/map.md +292 -15
- package/docs/tools.md +4 -4
- package/docs/troubleshooting.md +11 -11
- package/npm-shrinkwrap.json +109 -91
- package/package.json +3 -3
- package/schemas/configuration.schema.json +65 -58
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/geography-model.schema.json +1148 -0
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +5 -11
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +2325 -292
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +25 -7
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/reference-sheets.schema.json +267 -0
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydrology on the macro grid (ADR 0056): priority-flood conditioning with a slope epsilon, D8 flow
|
|
3
|
+
* and accumulation, named rivers routed as least-cost paths over the conditioned heights, an
|
|
4
|
+
* accumulation network above a threshold chosen for the target density, deltas, stream-power
|
|
5
|
+
* carving with floodplains, and river widths by catchment. The result is a list of macro-cell
|
|
6
|
+
* rivers that `river-raster.ts` turns into the one-pixel paths of `rivers.bmp`.
|
|
7
|
+
*/
|
|
8
|
+
import { ServiceError } from '../../core/result.js';
|
|
9
|
+
import { fillAreaStrip } from '../geography/index.js';
|
|
10
|
+
import { LAKE, LAND, MinHeap, NEIGHBOUR_DX, NEIGHBOUR_DY, NEIGHBOUR_LENGTH, SEA } from './grid.js';
|
|
11
|
+
import { LAND_FLOOR } from './macro-relief.js';
|
|
12
|
+
import { SQRT2, clamp, hash01, log2Approx, noise2, seedFromString, smoothstep } from './math.js';
|
|
13
|
+
/** Share of land pixels the river network should cover by default (vanilla: 2.28%). */
|
|
14
|
+
export const DEFAULT_RIVER_DENSITY = 0.02;
|
|
15
|
+
/** The most river pixels a map may carry: long networks slow the game. */
|
|
16
|
+
export const RIVER_PIXEL_CAP = 130_000;
|
|
17
|
+
/** Pixels written per macro cell of river, on average (orthogonal paths: between 2 and 4). */
|
|
18
|
+
const PIXELS_PER_CELL = 2.7;
|
|
19
|
+
/** A river may climb at most this much (map height units) along its route. */
|
|
20
|
+
export const MAXIMUM_RIVER_CLIMB = 22;
|
|
21
|
+
/**
|
|
22
|
+
* Rise per cell of the conditioned surface inside depressions and flats: a small base plus a hashed
|
|
23
|
+
* share, so flat ground drains along an irregular tree instead of straight lines to the nearest outlet.
|
|
24
|
+
*/
|
|
25
|
+
function slope(cell) {
|
|
26
|
+
const mixed = Math.imul(cell ^ 0x2545f491, 0x9e3779b1);
|
|
27
|
+
return 0.002 + 0.05 * (((mixed ^ (mixed >>> 15)) >>> 0) / 4294967296);
|
|
28
|
+
}
|
|
29
|
+
function isLandCell(kind, cell) {
|
|
30
|
+
return kind[cell] === LAND;
|
|
31
|
+
}
|
|
32
|
+
function priorityFlood(height, kind, mw, mh, sinks, heap) {
|
|
33
|
+
const total = mw * mh;
|
|
34
|
+
const filled = new Float32Array(total);
|
|
35
|
+
const receiver = new Int32Array(total).fill(-1);
|
|
36
|
+
const order = new Int32Array(total);
|
|
37
|
+
const seen = new Uint8Array(total);
|
|
38
|
+
heap.clear();
|
|
39
|
+
for (let cell = 0; cell < total; cell += 1) {
|
|
40
|
+
if (!isLandCell(kind, cell))
|
|
41
|
+
continue;
|
|
42
|
+
const x = cell % mw;
|
|
43
|
+
const y = (cell - x) / mw;
|
|
44
|
+
let outlet = sinks?.[cell] === 1;
|
|
45
|
+
if (!outlet)
|
|
46
|
+
for (let n = 0; n < 8 && !outlet; n += 1) {
|
|
47
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
48
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
49
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh || !isLandCell(kind, ny * mw + nx))
|
|
50
|
+
outlet = true;
|
|
51
|
+
}
|
|
52
|
+
if (!outlet)
|
|
53
|
+
continue;
|
|
54
|
+
seen[cell] = 1;
|
|
55
|
+
filled[cell] = height[cell];
|
|
56
|
+
heap.push(filled[cell], cell);
|
|
57
|
+
}
|
|
58
|
+
let count = 0;
|
|
59
|
+
while (heap.length > 0) {
|
|
60
|
+
const cell = heap.pop();
|
|
61
|
+
order[count++] = cell;
|
|
62
|
+
const x = cell % mw;
|
|
63
|
+
const y = (cell - x) / mw;
|
|
64
|
+
const level = filled[cell];
|
|
65
|
+
for (let n = 0; n < 8; n += 1) {
|
|
66
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
67
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
68
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh)
|
|
69
|
+
continue;
|
|
70
|
+
const next = ny * mw + nx;
|
|
71
|
+
if (seen[next] === 1 || !isLandCell(kind, next))
|
|
72
|
+
continue;
|
|
73
|
+
seen[next] = 1;
|
|
74
|
+
receiver[next] = cell;
|
|
75
|
+
const value = height[next];
|
|
76
|
+
const rise = level + slope(next);
|
|
77
|
+
filled[next] = value > rise ? value : rise;
|
|
78
|
+
heap.push(filled[next], next);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { filled, receiver, order, count };
|
|
82
|
+
}
|
|
83
|
+
function accumulate(flood, rain, kind) {
|
|
84
|
+
const acc = new Float32Array(rain.length);
|
|
85
|
+
for (let cell = 0; cell < acc.length; cell += 1)
|
|
86
|
+
acc[cell] = isLandCell(kind, cell) ? rain[cell] : 0;
|
|
87
|
+
for (let index = flood.count - 1; index >= 0; index -= 1) {
|
|
88
|
+
const cell = flood.order[index];
|
|
89
|
+
const receiver = flood.receiver[cell];
|
|
90
|
+
if (receiver >= 0)
|
|
91
|
+
acc[receiver] = acc[receiver] + acc[cell];
|
|
92
|
+
}
|
|
93
|
+
return acc;
|
|
94
|
+
}
|
|
95
|
+
function createRouter(height, kind, occupant, wander, mw, mh, heap) {
|
|
96
|
+
const total = mw * mh;
|
|
97
|
+
const dist = new Float32Array(total);
|
|
98
|
+
const stamp = new Int32Array(total);
|
|
99
|
+
const previous = new Int32Array(total);
|
|
100
|
+
let run = 0;
|
|
101
|
+
return {
|
|
102
|
+
route(spec) {
|
|
103
|
+
run += 1;
|
|
104
|
+
heap.clear();
|
|
105
|
+
const goalX = spec.goalCell === undefined ? 0 : spec.goalCell % mw;
|
|
106
|
+
const goalY = spec.goalCell === undefined ? 0 : (spec.goalCell - goalX) / mw;
|
|
107
|
+
const heuristic = (cell) => {
|
|
108
|
+
if (spec.goalCell === undefined)
|
|
109
|
+
return 0;
|
|
110
|
+
const x = cell % mw;
|
|
111
|
+
const dx = x - goalX;
|
|
112
|
+
const dy = (cell - x) / mw - goalY;
|
|
113
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
114
|
+
};
|
|
115
|
+
stamp[spec.start] = run;
|
|
116
|
+
dist[spec.start] = 0;
|
|
117
|
+
previous[spec.start] = -1;
|
|
118
|
+
heap.push(heuristic(spec.start), spec.start);
|
|
119
|
+
let expanded = 0;
|
|
120
|
+
const limit = spec.limit ?? total * 8;
|
|
121
|
+
while (heap.length > 0) {
|
|
122
|
+
const cell = heap.pop();
|
|
123
|
+
const cost = dist[cell];
|
|
124
|
+
if (heap.popKey > (cost + heuristic(cell)) * 1.0001 + 0.01)
|
|
125
|
+
continue;
|
|
126
|
+
let joined;
|
|
127
|
+
if (spec.goalCell !== undefined) {
|
|
128
|
+
if (cell === spec.goalCell)
|
|
129
|
+
joined = -1;
|
|
130
|
+
}
|
|
131
|
+
else if (spec.goalTest !== undefined)
|
|
132
|
+
joined = spec.goalTest(cell);
|
|
133
|
+
if (joined !== undefined) {
|
|
134
|
+
const cells = [];
|
|
135
|
+
for (let at = cell; at >= 0; at = previous[at])
|
|
136
|
+
cells.push(at);
|
|
137
|
+
cells.reverse();
|
|
138
|
+
return { cells, joined };
|
|
139
|
+
}
|
|
140
|
+
if (++expanded > limit)
|
|
141
|
+
return undefined;
|
|
142
|
+
const x = cell % mw;
|
|
143
|
+
const y = (cell - x) / mw;
|
|
144
|
+
for (let n = 0; n < 8; n += 1) {
|
|
145
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
146
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
147
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh)
|
|
148
|
+
continue;
|
|
149
|
+
const next = ny * mw + nx;
|
|
150
|
+
if (!isLandCell(kind, next))
|
|
151
|
+
continue;
|
|
152
|
+
const owner = occupant[next];
|
|
153
|
+
if (owner !== 0)
|
|
154
|
+
continue;
|
|
155
|
+
// Diagonal steps may not squeeze between two blocked cells.
|
|
156
|
+
if (n % 2 === 1) {
|
|
157
|
+
const a = y * mw + nx;
|
|
158
|
+
const b = ny * mw + x;
|
|
159
|
+
if (!isLandCell(kind, a) && !isLandCell(kind, b))
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const length = NEIGHBOUR_LENGTH[n];
|
|
163
|
+
const rise = height[next] - height[cell];
|
|
164
|
+
let step = length *
|
|
165
|
+
(1 +
|
|
166
|
+
4 * (rise > 0 ? rise : 0) +
|
|
167
|
+
0.01 * (height[next] - LAND_FLOOR) +
|
|
168
|
+
4 * wander[next]);
|
|
169
|
+
// Keep clear of other rivers' channels so the pixel paths, which bend a little, can stay apart.
|
|
170
|
+
let crowded = false;
|
|
171
|
+
for (let m = 0; m < 8 && !crowded; m += 1) {
|
|
172
|
+
const mx = nx + NEIGHBOUR_DX[m];
|
|
173
|
+
const my = ny + NEIGHBOUR_DY[m];
|
|
174
|
+
if (mx < 0 || my < 0 || mx >= mw || my >= mh)
|
|
175
|
+
continue;
|
|
176
|
+
const around = occupant[my * mw + mx];
|
|
177
|
+
if (around !== 0)
|
|
178
|
+
crowded = true;
|
|
179
|
+
}
|
|
180
|
+
if (crowded) {
|
|
181
|
+
const sx = nx - (spec.start % mw);
|
|
182
|
+
const sy = ny - Math.floor(spec.start / mw);
|
|
183
|
+
const nearStart = sx * sx + sy * sy <= 6;
|
|
184
|
+
if (!nearStart && spec.goalTest?.(next) === undefined)
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (spec.bias !== undefined) {
|
|
188
|
+
const along = (NEIGHBOUR_DX[n] * spec.bias.dx + NEIGHBOUR_DY[n] * spec.bias.dy) / length;
|
|
189
|
+
if (along < 0)
|
|
190
|
+
step += spec.bias.weight * -along * length;
|
|
191
|
+
}
|
|
192
|
+
const candidate = Math.fround(cost + step);
|
|
193
|
+
if (stamp[next] === run && dist[next] <= candidate)
|
|
194
|
+
continue;
|
|
195
|
+
stamp[next] = run;
|
|
196
|
+
dist[next] = candidate;
|
|
197
|
+
previous[next] = cell;
|
|
198
|
+
heap.push(Math.fround(candidate + heuristic(next)), next);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function nearestLandCell(kind, mw, mh, x, y, radius) {
|
|
206
|
+
const cx = clamp(Math.floor(x), 0, mw - 1);
|
|
207
|
+
const cy = clamp(Math.floor(y), 0, mh - 1);
|
|
208
|
+
if (isLandCell(kind, cy * mw + cx))
|
|
209
|
+
return cy * mw + cx;
|
|
210
|
+
let best = -1;
|
|
211
|
+
let bestDistance = Infinity;
|
|
212
|
+
for (let dy = -radius; dy <= radius; dy += 1)
|
|
213
|
+
for (let dx = -radius; dx <= radius; dx += 1) {
|
|
214
|
+
const nx = cx + dx;
|
|
215
|
+
const ny = cy + dy;
|
|
216
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh)
|
|
217
|
+
continue;
|
|
218
|
+
const cell = ny * mw + nx;
|
|
219
|
+
if (!isLandCell(kind, cell))
|
|
220
|
+
continue;
|
|
221
|
+
const d = dx * dx + dy * dy;
|
|
222
|
+
if (d < bestDistance) {
|
|
223
|
+
bestDistance = d;
|
|
224
|
+
best = cell;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return best;
|
|
228
|
+
}
|
|
229
|
+
function touchesWater(kind, mw, mh, cell, wanted) {
|
|
230
|
+
const x = cell % mw;
|
|
231
|
+
const y = (cell - x) / mw;
|
|
232
|
+
for (let n = 0; n < 8; n += 1) {
|
|
233
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
234
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
235
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh) {
|
|
236
|
+
if (wanted === -1)
|
|
237
|
+
return true;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (kind[ny * mw + nx] === wanted)
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
/** The highest a route rises above the lowest point it has already passed: how far it runs uphill. */
|
|
246
|
+
function climbOf(height, cells) {
|
|
247
|
+
let lowest = Infinity;
|
|
248
|
+
let climb = 0;
|
|
249
|
+
for (const cell of cells) {
|
|
250
|
+
const value = height[cell];
|
|
251
|
+
if (value < lowest)
|
|
252
|
+
lowest = value;
|
|
253
|
+
else if (value - lowest > climb)
|
|
254
|
+
climb = value - lowest;
|
|
255
|
+
}
|
|
256
|
+
return climb;
|
|
257
|
+
}
|
|
258
|
+
// ---------------------------------------------------------------------------------------------
|
|
259
|
+
function fail(code, message, details) {
|
|
260
|
+
throw new ServiceError(code, message, details);
|
|
261
|
+
}
|
|
262
|
+
function riverSourceLake(river) {
|
|
263
|
+
return river.source.type === 'lake' ? river.source.id : undefined;
|
|
264
|
+
}
|
|
265
|
+
function onEdge(mw, mh, cell) {
|
|
266
|
+
const x = cell % mw;
|
|
267
|
+
const y = (cell - x) / mw;
|
|
268
|
+
return x === 0 || y === 0 || x === mw - 1 || y === mh - 1;
|
|
269
|
+
}
|
|
270
|
+
/** The macro cell holding a frame point, moved to the nearest land cell within `radius`. */
|
|
271
|
+
function landCellAt(macro, x, y, radius) {
|
|
272
|
+
return nearestLandCell(macro.kind, macro.width, macro.height, x * macro.width, y * macro.height, radius);
|
|
273
|
+
}
|
|
274
|
+
function foreignCell(occupant, mw, mh, cell, own) {
|
|
275
|
+
const x = cell % mw;
|
|
276
|
+
const y = (cell - x) / mw;
|
|
277
|
+
for (let n = 0; n < 8; n += 1) {
|
|
278
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
279
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
280
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh)
|
|
281
|
+
continue;
|
|
282
|
+
const owner = occupant[ny * mw + nx];
|
|
283
|
+
if (owner !== 0 && owner !== own)
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
return occupant[cell] !== 0 && occupant[cell] !== own;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Give a least-cost route natural bends: its smoothed line is displaced sideways by a smooth noise
|
|
290
|
+
* that dies away at both ends, and the result is traced back to 8-connected cells. A displaced route
|
|
291
|
+
* that leaves land, touches another river or climbs noticeably is retried with half the amplitude,
|
|
292
|
+
* and finally dropped.
|
|
293
|
+
*/
|
|
294
|
+
function meanderRoute(cells, own, context, name) {
|
|
295
|
+
const n = cells.length;
|
|
296
|
+
if (n < 30)
|
|
297
|
+
return cells;
|
|
298
|
+
const { occupant, kind, mw, mh } = context;
|
|
299
|
+
const sx = new Float64Array(n);
|
|
300
|
+
const sy = new Float64Array(n);
|
|
301
|
+
for (let i = 0; i < n; i += 1) {
|
|
302
|
+
const lo = Math.max(0, i - 4);
|
|
303
|
+
const hi = Math.min(n - 1, i + 4);
|
|
304
|
+
let x = 0;
|
|
305
|
+
let y = 0;
|
|
306
|
+
for (let j = lo; j <= hi; j += 1) {
|
|
307
|
+
x += (cells[j] % mw) + 0.5;
|
|
308
|
+
y += Math.floor(cells[j] / mw) + 0.5;
|
|
309
|
+
}
|
|
310
|
+
sx[i] = x / (hi - lo + 1);
|
|
311
|
+
sy[i] = y / (hi - lo + 1);
|
|
312
|
+
}
|
|
313
|
+
const arc = new Float64Array(n);
|
|
314
|
+
for (let i = 1; i < n; i += 1) {
|
|
315
|
+
const ex = sx[i] - sx[i - 1];
|
|
316
|
+
const ey = sy[i] - sy[i - 1];
|
|
317
|
+
arc[i] = arc[i - 1] + Math.sqrt(ex * ex + ey * ey);
|
|
318
|
+
}
|
|
319
|
+
const originalClimb = climbOf(context.height, cells);
|
|
320
|
+
const seed = seedFromString(context.seed, `meander:${name}`);
|
|
321
|
+
const keep = 4;
|
|
322
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
323
|
+
const amplitude = clamp(0.09 * n, 2, 13) / (1 << attempt);
|
|
324
|
+
const points = [];
|
|
325
|
+
for (let i = keep; i < n - keep; i += 1) {
|
|
326
|
+
const a = Math.max(0, i - 2);
|
|
327
|
+
const b = Math.min(n - 1, i + 2);
|
|
328
|
+
let tx = sx[b] - sx[a];
|
|
329
|
+
let ty = sy[b] - sy[a];
|
|
330
|
+
const length = Math.sqrt(tx * tx + ty * ty);
|
|
331
|
+
if (length === 0)
|
|
332
|
+
continue;
|
|
333
|
+
tx /= length;
|
|
334
|
+
ty /= length;
|
|
335
|
+
const taper = smoothstep(Math.min(i, n - 1 - i) / 10);
|
|
336
|
+
const offset = clamp((noise2(seed, arc[i] / 26, 0.37) * 2 - 1) * 1.7, -1, 1) * amplitude * taper;
|
|
337
|
+
points.push([sx[i] - ty * offset, sy[i] + tx * offset]);
|
|
338
|
+
}
|
|
339
|
+
const chain = cells.slice(0, keep);
|
|
340
|
+
let valid = true;
|
|
341
|
+
let previous = cells[keep - 1];
|
|
342
|
+
const visit = (cell) => {
|
|
343
|
+
if (cell === chain[chain.length - 1])
|
|
344
|
+
return true;
|
|
345
|
+
if (kind[cell] !== LAND || foreignCell(occupant, mw, mh, cell, own))
|
|
346
|
+
return false;
|
|
347
|
+
if (context.reserved?.[cell] === 1 && occupant[cell] !== own)
|
|
348
|
+
return false;
|
|
349
|
+
chain.push(cell);
|
|
350
|
+
return true;
|
|
351
|
+
};
|
|
352
|
+
const lineTo = (tx, ty) => {
|
|
353
|
+
const fx = previous % mw;
|
|
354
|
+
const fy = Math.floor(previous / mw);
|
|
355
|
+
const steps = Math.max(Math.abs(tx - fx), Math.abs(ty - fy));
|
|
356
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
357
|
+
const x = Math.round(fx + ((tx - fx) * step) / steps);
|
|
358
|
+
const y = Math.round(fy + ((ty - fy) * step) / steps);
|
|
359
|
+
if (x < 0 || y < 0 || x >= mw || y >= mh)
|
|
360
|
+
return false;
|
|
361
|
+
if (!visit(y * mw + x))
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
previous = chain[chain.length - 1];
|
|
365
|
+
return true;
|
|
366
|
+
};
|
|
367
|
+
for (const [px, py] of points) {
|
|
368
|
+
if (!lineTo(Math.floor(px), Math.floor(py))) {
|
|
369
|
+
valid = false;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (valid)
|
|
374
|
+
for (const cell of cells.slice(n - keep)) {
|
|
375
|
+
if (!lineTo(cell % mw, Math.floor(cell / mw))) {
|
|
376
|
+
valid = false;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!valid)
|
|
381
|
+
continue;
|
|
382
|
+
// Drop loops: a cell visited twice cuts the chain back to its first visit.
|
|
383
|
+
const seen = new Map();
|
|
384
|
+
const result = [];
|
|
385
|
+
for (const cell of chain) {
|
|
386
|
+
const at = seen.get(cell);
|
|
387
|
+
if (at !== undefined) {
|
|
388
|
+
while (result.length > at + 1)
|
|
389
|
+
seen.delete(result.pop());
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
seen.set(cell, result.length);
|
|
393
|
+
result.push(cell);
|
|
394
|
+
}
|
|
395
|
+
if (climbOf(context.height, result) > originalClimb + 8)
|
|
396
|
+
continue;
|
|
397
|
+
for (const cell of cells)
|
|
398
|
+
if (occupant[cell] === own)
|
|
399
|
+
occupant[cell] = 0;
|
|
400
|
+
for (const cell of result)
|
|
401
|
+
occupant[cell] = own;
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
return cells;
|
|
405
|
+
}
|
|
406
|
+
function routeNamedRivers(context) {
|
|
407
|
+
const { geography, macro, router, occupant, lakeOf, flood, conditioned, notes } = context;
|
|
408
|
+
const mw = macro.width;
|
|
409
|
+
const mh = macro.height;
|
|
410
|
+
const kind = macro.kind;
|
|
411
|
+
const routed = [];
|
|
412
|
+
const done = new Set();
|
|
413
|
+
const pending = [...geography.rivers].sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
414
|
+
const lakeIndex = new Map(geography.lakes.map((lake, index) => [lake.id, index + 1]));
|
|
415
|
+
while (pending.length > 0) {
|
|
416
|
+
const ready = pending.filter((river) => river.mouth.type !== 'river' || done.has(river.mouth.id));
|
|
417
|
+
if (ready.length === 0)
|
|
418
|
+
fail('MAP_RIVER_CYCLE', 'Rivers flow into each other in a cycle', {
|
|
419
|
+
rivers: pending.map((river) => river.id).slice(0, 20),
|
|
420
|
+
});
|
|
421
|
+
for (const river of ready) {
|
|
422
|
+
context.signal?.throwIfAborted();
|
|
423
|
+
pending.splice(pending.indexOf(river), 1);
|
|
424
|
+
const ownId = routed.length + 1;
|
|
425
|
+
let start;
|
|
426
|
+
function unreachable(reason, details = {}) {
|
|
427
|
+
return fail('MAP_RIVER_UNREACHABLE_MOUTH', `River ${river.id} cannot reach its mouth: ${reason}`, { riverId: river.id, ...details });
|
|
428
|
+
}
|
|
429
|
+
// The first via point or the mouth point steers the choice of a lake's outlet.
|
|
430
|
+
const firstVia = river.via[0];
|
|
431
|
+
if (river.source.type === 'lake') {
|
|
432
|
+
const index = lakeIndex.get(river.source.id);
|
|
433
|
+
const lake = geography.get('lake', river.source.id);
|
|
434
|
+
const x0 = Math.max(0, Math.floor(lake.area.bounds.x0 * mw) - 2);
|
|
435
|
+
const x1 = Math.min(mw - 1, Math.ceil(lake.area.bounds.x1 * mw) + 2);
|
|
436
|
+
const y0 = Math.max(0, Math.floor(lake.area.bounds.y0 * mh) - 2);
|
|
437
|
+
const y1 = Math.min(mh - 1, Math.ceil(lake.area.bounds.y1 * mh) + 2);
|
|
438
|
+
let best = -1;
|
|
439
|
+
let bestScore = Infinity;
|
|
440
|
+
for (let y = y0; y <= y1; y += 1)
|
|
441
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
442
|
+
const cell = y * mw + x;
|
|
443
|
+
if (!isLandCell(kind, cell) || occupant[cell] !== 0)
|
|
444
|
+
continue;
|
|
445
|
+
let rim = false;
|
|
446
|
+
for (let n = 0; n < 8 && !rim; n += 1) {
|
|
447
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
448
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
449
|
+
if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && lakeOf[ny * mw + nx] === index)
|
|
450
|
+
rim = true;
|
|
451
|
+
}
|
|
452
|
+
if (!rim)
|
|
453
|
+
continue;
|
|
454
|
+
const score = firstVia === undefined
|
|
455
|
+
? conditioned[cell]
|
|
456
|
+
: (x + 0.5 - firstVia.x * mw) * (x + 0.5 - firstVia.x * mw) +
|
|
457
|
+
(y + 0.5 - firstVia.y * mh) * (y + 0.5 - firstVia.y * mh);
|
|
458
|
+
if (score < bestScore) {
|
|
459
|
+
bestScore = score;
|
|
460
|
+
best = cell;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (best < 0)
|
|
464
|
+
unreachable(`its source lake ${river.source.id} has no free shore`);
|
|
465
|
+
start = best;
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
if (river.source.type !== 'point')
|
|
469
|
+
return fail('MAP_RIVER_SOURCE_NOT_LAND', `River ${river.id} must start at a point or a lake`, {
|
|
470
|
+
riverId: river.id,
|
|
471
|
+
});
|
|
472
|
+
start = landCellAt(macro, river.source.x, river.source.y, 8);
|
|
473
|
+
if (start < 0)
|
|
474
|
+
fail('MAP_RIVER_SOURCE_NOT_LAND', `The source of river ${river.id} has no land within reach`, {
|
|
475
|
+
riverId: river.id,
|
|
476
|
+
});
|
|
477
|
+
if (occupant[start] !== 0) {
|
|
478
|
+
let free = -1;
|
|
479
|
+
for (let r = 1; r <= 3 && free < 0; r += 1)
|
|
480
|
+
for (let dy = -r; dy <= r && free < 0; dy += 1)
|
|
481
|
+
for (let dx = -r; dx <= r && free < 0; dx += 1) {
|
|
482
|
+
const x = (start % mw) + dx;
|
|
483
|
+
const y = Math.floor(start / mw) + dy;
|
|
484
|
+
if (x < 0 || y < 0 || x >= mw || y >= mh)
|
|
485
|
+
continue;
|
|
486
|
+
const cell = y * mw + x;
|
|
487
|
+
if (isLandCell(kind, cell) && occupant[cell] === 0)
|
|
488
|
+
free = cell;
|
|
489
|
+
}
|
|
490
|
+
if (free < 0)
|
|
491
|
+
unreachable('its source is already taken by another river');
|
|
492
|
+
start = free;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const cells = [start];
|
|
496
|
+
occupant[start] = ownId;
|
|
497
|
+
let current = start;
|
|
498
|
+
const append = (leg) => {
|
|
499
|
+
for (let index = 1; index < leg.length; index += 1) {
|
|
500
|
+
cells.push(leg[index]);
|
|
501
|
+
occupant[leg[index]] = ownId;
|
|
502
|
+
}
|
|
503
|
+
current = cells[cells.length - 1];
|
|
504
|
+
};
|
|
505
|
+
for (const via of river.via) {
|
|
506
|
+
const target = landCellAt(macro, via.x, via.y, 6);
|
|
507
|
+
if (target < 0 || target === current || occupant[target] !== 0)
|
|
508
|
+
continue;
|
|
509
|
+
const leg = router.route({ start: current, goalCell: target });
|
|
510
|
+
if (leg === undefined)
|
|
511
|
+
unreachable('a via point cannot be reached over land', { via: { x: via.x, y: via.y } });
|
|
512
|
+
append(leg.cells);
|
|
513
|
+
}
|
|
514
|
+
let joinCell = -1;
|
|
515
|
+
let parentId;
|
|
516
|
+
let endsIn = 'sea';
|
|
517
|
+
const mouth = river.mouth;
|
|
518
|
+
let final;
|
|
519
|
+
// A river that has run a while keeps its general direction toward an open "sea" mouth.
|
|
520
|
+
let bias;
|
|
521
|
+
if (cells.length >= 12) {
|
|
522
|
+
const dx = (current % mw) - (start % mw);
|
|
523
|
+
const dy = Math.floor(current / mw) - Math.floor(start / mw);
|
|
524
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
525
|
+
if (length >= 8)
|
|
526
|
+
bias = { dx: dx / length, dy: dy / length, weight: 1.5 };
|
|
527
|
+
}
|
|
528
|
+
if (mouth.type === 'sea') {
|
|
529
|
+
final = router.route({
|
|
530
|
+
start: current,
|
|
531
|
+
goalTest: (cell) => touchesWater(kind, mw, mh, cell, SEA) || onEdge(mw, mh, cell) ? -1 : undefined,
|
|
532
|
+
...(bias === undefined ? {} : { bias }),
|
|
533
|
+
});
|
|
534
|
+
if (final === undefined)
|
|
535
|
+
unreachable(`no coast of ${mouth.id} lies on its land`);
|
|
536
|
+
}
|
|
537
|
+
else if (mouth.type === 'lake') {
|
|
538
|
+
const index = lakeIndex.get(mouth.id);
|
|
539
|
+
final = router.route({
|
|
540
|
+
start: current,
|
|
541
|
+
goalTest: (cell) => {
|
|
542
|
+
const x = cell % mw;
|
|
543
|
+
const y = (cell - x) / mw;
|
|
544
|
+
for (let n = 0; n < 8; n += 1) {
|
|
545
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
546
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
547
|
+
if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && lakeOf[ny * mw + nx] === index)
|
|
548
|
+
return -1;
|
|
549
|
+
}
|
|
550
|
+
return undefined;
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
if (final === undefined)
|
|
554
|
+
unreachable(`lake ${mouth.id} is not reachable over land`);
|
|
555
|
+
endsIn = 'lake';
|
|
556
|
+
}
|
|
557
|
+
else if (mouth.type === 'river') {
|
|
558
|
+
const parentIndex = routed.findIndex((route) => route.river.id === mouth.id);
|
|
559
|
+
const parentOwner = parentIndex + 1;
|
|
560
|
+
final = router.route({
|
|
561
|
+
start: current,
|
|
562
|
+
goalTest: (cell) => {
|
|
563
|
+
const x = cell % mw;
|
|
564
|
+
const y = (cell - x) / mw;
|
|
565
|
+
for (let n = 0; n < 8; n += 1) {
|
|
566
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
567
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
568
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh)
|
|
569
|
+
continue;
|
|
570
|
+
if (occupant[ny * mw + nx] === parentOwner)
|
|
571
|
+
return ny * mw + nx;
|
|
572
|
+
}
|
|
573
|
+
return undefined;
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
if (final === undefined)
|
|
577
|
+
unreachable(`river ${mouth.id} is not reachable over land`);
|
|
578
|
+
parentId = mouth.id;
|
|
579
|
+
joinCell = final.joined;
|
|
580
|
+
endsIn = 'river';
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
const target = landCellAt(macro, mouth.x, mouth.y, 8);
|
|
584
|
+
if (target < 0)
|
|
585
|
+
unreachable('its mouth lies far from any land');
|
|
586
|
+
final =
|
|
587
|
+
target === current
|
|
588
|
+
? { cells: [current], joined: -1 }
|
|
589
|
+
: router.route({ start: current, goalCell: target });
|
|
590
|
+
if (final === undefined)
|
|
591
|
+
unreachable('its mouth cannot be reached over land');
|
|
592
|
+
}
|
|
593
|
+
append(final.cells);
|
|
594
|
+
if (mouth.type === 'point') {
|
|
595
|
+
// Past a mouth drawn on land, the river follows the conditioned flow to the water.
|
|
596
|
+
let guard = 4096;
|
|
597
|
+
while (guard-- > 0 &&
|
|
598
|
+
!touchesWater(kind, mw, mh, current, SEA) &&
|
|
599
|
+
!touchesWater(kind, mw, mh, current, LAKE) &&
|
|
600
|
+
!onEdge(mw, mh, current)) {
|
|
601
|
+
const next = flood.receiver[current];
|
|
602
|
+
if (next < 0 || occupant[next] !== 0)
|
|
603
|
+
break;
|
|
604
|
+
cells.push(next);
|
|
605
|
+
occupant[next] = ownId;
|
|
606
|
+
current = next;
|
|
607
|
+
}
|
|
608
|
+
endsIn = touchesWater(kind, mw, mh, current, LAKE)
|
|
609
|
+
? 'lake'
|
|
610
|
+
: touchesWater(kind, mw, mh, current, SEA)
|
|
611
|
+
? 'sea'
|
|
612
|
+
: 'edge';
|
|
613
|
+
}
|
|
614
|
+
else if (mouth.type === 'sea')
|
|
615
|
+
endsIn = touchesWater(kind, mw, mh, current, SEA) ? 'sea' : 'edge';
|
|
616
|
+
cells.splice(0, cells.length, ...meanderRoute(cells, ownId, { occupant, kind, height: conditioned, mw, mh, seed: context.seed }, river.id));
|
|
617
|
+
const climb = climbOf(conditioned, cells);
|
|
618
|
+
if (climb > MAXIMUM_RIVER_CLIMB)
|
|
619
|
+
unreachable(`its route would climb ${Math.round(climb)} height units over a divide (the limit is ${MAXIMUM_RIVER_CLIMB})`, { climb: Math.round(climb) });
|
|
620
|
+
routed.push({
|
|
621
|
+
river,
|
|
622
|
+
cells,
|
|
623
|
+
...(parentId === undefined ? {} : { parentId }),
|
|
624
|
+
joinCell,
|
|
625
|
+
endsIn,
|
|
626
|
+
climb,
|
|
627
|
+
});
|
|
628
|
+
done.add(river.id);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
void notes;
|
|
632
|
+
return routed;
|
|
633
|
+
}
|
|
634
|
+
// ---------------------------------------------------------------------------------------------
|
|
635
|
+
// Deltas
|
|
636
|
+
const DELTA_COS = 0.7660444;
|
|
637
|
+
const DELTA_SIN = 0.6427876;
|
|
638
|
+
function addDeltas(rivers, named, geography, router, occupant, kind, mw, mh, notes) {
|
|
639
|
+
void geography;
|
|
640
|
+
const count = named.length;
|
|
641
|
+
for (let index = 0; index < count; index += 1) {
|
|
642
|
+
const route = named[index];
|
|
643
|
+
if (!route.river.delta || (route.endsIn !== 'sea' && route.endsIn !== 'lake'))
|
|
644
|
+
continue;
|
|
645
|
+
const n = route.cells.length;
|
|
646
|
+
if (n < 14) {
|
|
647
|
+
notes.push({
|
|
648
|
+
code: 'MAP_RIVER_DELTA_SKIPPED',
|
|
649
|
+
severity: 'info',
|
|
650
|
+
message: `River ${route.river.id} is too short for a delta`,
|
|
651
|
+
details: { riverId: route.river.id },
|
|
652
|
+
});
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const b = n - 1 - clamp(Math.round(0.14 * n), 6, 28);
|
|
656
|
+
const branch = route.cells[b];
|
|
657
|
+
const last = route.cells[n - 1];
|
|
658
|
+
const bx = branch % mw;
|
|
659
|
+
const by = (branch - bx) / mw;
|
|
660
|
+
let dx = (last % mw) - bx;
|
|
661
|
+
let dy = Math.floor(last / mw) - by;
|
|
662
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
663
|
+
if (length === 0)
|
|
664
|
+
continue;
|
|
665
|
+
dx /= length;
|
|
666
|
+
dy /= length;
|
|
667
|
+
let made = 0;
|
|
668
|
+
for (const sign of [1, -1]) {
|
|
669
|
+
const rx = dx * DELTA_COS - dy * DELTA_SIN * sign;
|
|
670
|
+
const ry = dx * DELTA_SIN * sign + dy * DELTA_COS;
|
|
671
|
+
// The first cell is a free neighbour of the branch cell on this side of the main channel.
|
|
672
|
+
let start = -1;
|
|
673
|
+
let best = -Infinity;
|
|
674
|
+
for (let k = 0; k < 8; k += 1) {
|
|
675
|
+
const x = bx + NEIGHBOUR_DX[k];
|
|
676
|
+
const y = by + NEIGHBOUR_DY[k];
|
|
677
|
+
if (x < 0 || y < 0 || x >= mw || y >= mh)
|
|
678
|
+
continue;
|
|
679
|
+
const cell = y * mw + x;
|
|
680
|
+
if (!isLandCell(kind, cell) || occupant[cell] !== 0)
|
|
681
|
+
continue;
|
|
682
|
+
const score = NEIGHBOUR_DX[k] * rx + NEIGHBOUR_DY[k] * ry;
|
|
683
|
+
if (score > best) {
|
|
684
|
+
best = score;
|
|
685
|
+
start = cell;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (start < 0)
|
|
689
|
+
continue;
|
|
690
|
+
const ownId = rivers.length + 1;
|
|
691
|
+
occupant[start] = ownId;
|
|
692
|
+
// The channel fans out at an angle and ends at the first coast it reaches.
|
|
693
|
+
const found = router.route({
|
|
694
|
+
start,
|
|
695
|
+
goalTest: (cell) => touchesWater(kind, mw, mh, cell, route.endsIn === 'lake' ? LAKE : SEA) ? -1 : undefined,
|
|
696
|
+
bias: { dx: rx, dy: ry, weight: 3 },
|
|
697
|
+
limit: 20_000,
|
|
698
|
+
});
|
|
699
|
+
if (found === undefined || found.cells.length > 4 * (n - 1 - b) + 16) {
|
|
700
|
+
occupant[start] = 0;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const cells = found.cells;
|
|
704
|
+
for (const cell of cells)
|
|
705
|
+
occupant[cell] = ownId;
|
|
706
|
+
const parentIndex = rivers.findIndex((river) => river.id === route.river.id);
|
|
707
|
+
const parent = rivers[parentIndex];
|
|
708
|
+
const at = Math.max(0, parent.cells.indexOf(branch));
|
|
709
|
+
const share = 0.4 * parent.catchment[at];
|
|
710
|
+
rivers.push({
|
|
711
|
+
id: `${route.river.id}~delta${made + 1}`,
|
|
712
|
+
...(route.river.name === undefined ? {} : { name: route.river.name }),
|
|
713
|
+
named: true,
|
|
714
|
+
cells: Int32Array.from(cells),
|
|
715
|
+
parent: parentIndex,
|
|
716
|
+
joinCell: branch,
|
|
717
|
+
junction: 'distributary',
|
|
718
|
+
size: Math.max(1, route.river.size - 1),
|
|
719
|
+
endsIn: route.endsIn,
|
|
720
|
+
catchment: new Float32Array(cells.length).fill(share),
|
|
721
|
+
width: new Uint8Array(cells.length),
|
|
722
|
+
order: 1,
|
|
723
|
+
climb: 0,
|
|
724
|
+
});
|
|
725
|
+
made += 1;
|
|
726
|
+
}
|
|
727
|
+
if (made === 0)
|
|
728
|
+
notes.push({
|
|
729
|
+
code: 'MAP_RIVER_DELTA_SKIPPED',
|
|
730
|
+
severity: 'info',
|
|
731
|
+
message: `River ${route.river.id} has no room for distributaries near its mouth`,
|
|
732
|
+
details: { riverId: route.river.id },
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function selectStreams(input) {
|
|
737
|
+
const { flood, acc, kind, channel, mw, mh } = input;
|
|
738
|
+
const total = acc.length;
|
|
739
|
+
const isStream = new Uint8Array(total);
|
|
740
|
+
const minimum = 6;
|
|
741
|
+
if (input.budgetCells < 8 || input.count === 0)
|
|
742
|
+
return { isStream, threshold: Infinity };
|
|
743
|
+
let candidates = 0;
|
|
744
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
745
|
+
if (isLandCell(kind, cell) && channel[cell] === 0 && acc[cell] >= minimum)
|
|
746
|
+
candidates += 1;
|
|
747
|
+
if (candidates === 0)
|
|
748
|
+
return { isStream, threshold: Infinity };
|
|
749
|
+
const values = new Float32Array(candidates);
|
|
750
|
+
let fill = 0;
|
|
751
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
752
|
+
if (isLandCell(kind, cell) && channel[cell] === 0 && acc[cell] >= minimum)
|
|
753
|
+
values[fill++] = acc[cell];
|
|
754
|
+
values.sort();
|
|
755
|
+
const keep = Math.min(candidates, Math.floor(input.budgetCells));
|
|
756
|
+
const threshold = Math.max(minimum, values[candidates - keep]);
|
|
757
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
758
|
+
if (isLandCell(kind, cell) && channel[cell] === 0 && acc[cell] >= threshold)
|
|
759
|
+
isStream[cell] = 1;
|
|
760
|
+
// Systems: the trees that drain to one outlet. Keep the largest when a count is given and drop dust.
|
|
761
|
+
const root = new Int32Array(total).fill(-1);
|
|
762
|
+
const size = new Int32Array(total);
|
|
763
|
+
for (let index = 0; index < flood.count; index += 1) {
|
|
764
|
+
const cell = flood.order[index];
|
|
765
|
+
if (isStream[cell] === 0)
|
|
766
|
+
continue;
|
|
767
|
+
const receiver = flood.receiver[cell];
|
|
768
|
+
const rootCell = receiver >= 0 && isStream[receiver] === 1 ? root[receiver] : cell;
|
|
769
|
+
root[cell] = rootCell;
|
|
770
|
+
size[rootCell] = size[rootCell] + 1;
|
|
771
|
+
}
|
|
772
|
+
const roots = [];
|
|
773
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
774
|
+
if (isStream[cell] === 1 && root[cell] === cell)
|
|
775
|
+
roots.push(cell);
|
|
776
|
+
roots.sort((a, b) => acc[b] - acc[a] || a - b);
|
|
777
|
+
const keepRoot = new Set();
|
|
778
|
+
for (const cell of roots) {
|
|
779
|
+
if (size[cell] < 8)
|
|
780
|
+
continue;
|
|
781
|
+
if (input.count !== undefined && keepRoot.size >= input.count)
|
|
782
|
+
break;
|
|
783
|
+
keepRoot.add(cell);
|
|
784
|
+
}
|
|
785
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
786
|
+
if (isStream[cell] === 1 && !keepRoot.has(root[cell]))
|
|
787
|
+
isStream[cell] = 0;
|
|
788
|
+
pruneStubs(isStream, flood, mw, mh);
|
|
789
|
+
return { isStream, threshold };
|
|
790
|
+
}
|
|
791
|
+
/** Remove short headwater stubs (fewer than 5 cells between a source and the next confluence). */
|
|
792
|
+
function pruneStubs(isStream, flood, mw, mh) {
|
|
793
|
+
const total = isStream.length;
|
|
794
|
+
const children = new Uint8Array(total);
|
|
795
|
+
const countChildren = () => {
|
|
796
|
+
children.fill(0);
|
|
797
|
+
for (let cell = 0; cell < total; cell += 1) {
|
|
798
|
+
if (isStream[cell] === 0)
|
|
799
|
+
continue;
|
|
800
|
+
const receiver = flood.receiver[cell];
|
|
801
|
+
if (receiver >= 0 && isStream[receiver] === 1)
|
|
802
|
+
children[receiver] = children[receiver] + 1;
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
void mw;
|
|
806
|
+
void mh;
|
|
807
|
+
for (let round = 0; round < 3; round += 1) {
|
|
808
|
+
countChildren();
|
|
809
|
+
let removed = 0;
|
|
810
|
+
for (let cell = 0; cell < total; cell += 1) {
|
|
811
|
+
if (isStream[cell] === 0 || children[cell] !== 0)
|
|
812
|
+
continue;
|
|
813
|
+
const path = [];
|
|
814
|
+
let at = cell;
|
|
815
|
+
while (isStream[at] === 1 && children[at] <= 1) {
|
|
816
|
+
path.push(at);
|
|
817
|
+
const receiver = flood.receiver[at];
|
|
818
|
+
if (receiver < 0 || isStream[receiver] === 0)
|
|
819
|
+
break;
|
|
820
|
+
if (children[receiver] >= 2)
|
|
821
|
+
break;
|
|
822
|
+
at = receiver;
|
|
823
|
+
}
|
|
824
|
+
// A stub that ends at a confluence (or an outlet with a sibling) is short.
|
|
825
|
+
const last = path[path.length - 1];
|
|
826
|
+
const receiver = flood.receiver[last];
|
|
827
|
+
const joins = receiver >= 0 && isStream[receiver] === 1 && children[receiver] >= 2;
|
|
828
|
+
if (joins && path.length < 5) {
|
|
829
|
+
for (const stub of path)
|
|
830
|
+
isStream[stub] = 0;
|
|
831
|
+
removed += 1;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (removed === 0)
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function decomposeStreams(input) {
|
|
839
|
+
const { stream, flood, acc, channel, named, rivers, riverIndex, mw, mh } = input;
|
|
840
|
+
const { isStream } = stream;
|
|
841
|
+
const total = isStream.length;
|
|
842
|
+
// Which named river owns each channel cell.
|
|
843
|
+
const channelOwner = new Map();
|
|
844
|
+
for (const route of named) {
|
|
845
|
+
const index = riverIndex.get(route.river.id);
|
|
846
|
+
for (const cell of route.cells)
|
|
847
|
+
channelOwner.set(cell, index);
|
|
848
|
+
}
|
|
849
|
+
void channel;
|
|
850
|
+
const roots = [];
|
|
851
|
+
for (let cell = 0; cell < total; cell += 1) {
|
|
852
|
+
if (isStream[cell] === 0)
|
|
853
|
+
continue;
|
|
854
|
+
const receiver = flood.receiver[cell];
|
|
855
|
+
if (receiver < 0 || isStream[receiver] === 0)
|
|
856
|
+
roots.push(cell);
|
|
857
|
+
}
|
|
858
|
+
roots.sort((a, b) => acc[b] - acc[a] || a - b);
|
|
859
|
+
let counter = 0;
|
|
860
|
+
for (const rootCell of roots) {
|
|
861
|
+
const receiver = flood.receiver[rootCell];
|
|
862
|
+
const owner = receiver >= 0 ? channelOwner.get(receiver) : undefined;
|
|
863
|
+
const stack = [
|
|
864
|
+
{
|
|
865
|
+
cell: rootCell,
|
|
866
|
+
parent: owner ?? -1,
|
|
867
|
+
join: owner === undefined ? -1 : receiver,
|
|
868
|
+
ends: owner === undefined ? 'sea' : 'river',
|
|
869
|
+
},
|
|
870
|
+
];
|
|
871
|
+
while (stack.length > 0) {
|
|
872
|
+
// River ids are 16 bits wide; the network stops growing long before that matters.
|
|
873
|
+
if (rivers.length >= 30_000)
|
|
874
|
+
return;
|
|
875
|
+
const item = stack.pop();
|
|
876
|
+
let chain = [];
|
|
877
|
+
let cell = item.cell;
|
|
878
|
+
const reserved = rivers.length;
|
|
879
|
+
const spawnedFrom = stack.length;
|
|
880
|
+
for (;;) {
|
|
881
|
+
chain.push(cell);
|
|
882
|
+
const x = cell % mw;
|
|
883
|
+
const y = (cell - x) / mw;
|
|
884
|
+
let best = -1;
|
|
885
|
+
const others = [];
|
|
886
|
+
for (let n = 0; n < 8; n += 1) {
|
|
887
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
888
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
889
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh)
|
|
890
|
+
continue;
|
|
891
|
+
const next = ny * mw + nx;
|
|
892
|
+
if (isStream[next] === 0 || flood.receiver[next] !== cell)
|
|
893
|
+
continue;
|
|
894
|
+
if (best < 0 || acc[next] > acc[best] || (acc[next] === acc[best] && next < best)) {
|
|
895
|
+
if (best >= 0)
|
|
896
|
+
others.push(best);
|
|
897
|
+
best = next;
|
|
898
|
+
}
|
|
899
|
+
else
|
|
900
|
+
others.push(next);
|
|
901
|
+
}
|
|
902
|
+
if (best < 0)
|
|
903
|
+
break;
|
|
904
|
+
others.sort((a, b) => acc[a] - acc[b] || b - a);
|
|
905
|
+
for (const other of others)
|
|
906
|
+
stack.push({ cell: other, parent: reserved, join: cell, ends: 'river' });
|
|
907
|
+
cell = best;
|
|
908
|
+
}
|
|
909
|
+
chain.reverse();
|
|
910
|
+
const original = chain;
|
|
911
|
+
const id = `stream:${++counter}`;
|
|
912
|
+
if (chain.length >= 30 && rivers.length < 30_000) {
|
|
913
|
+
const own = rivers.length + 1;
|
|
914
|
+
for (const c of chain)
|
|
915
|
+
input.occupant[c] = own;
|
|
916
|
+
chain = meanderRoute(chain, own, {
|
|
917
|
+
occupant: input.occupant,
|
|
918
|
+
kind: input.kind,
|
|
919
|
+
height: input.height,
|
|
920
|
+
mw,
|
|
921
|
+
mh,
|
|
922
|
+
seed: input.seed,
|
|
923
|
+
reserved: isStream,
|
|
924
|
+
}, id);
|
|
925
|
+
if (chain !== original)
|
|
926
|
+
// Tributaries that were to join a cell of the old route join the nearest cell of the new one.
|
|
927
|
+
for (let k = spawnedFrom; k < stack.length; k += 1) {
|
|
928
|
+
const spawned = stack[k];
|
|
929
|
+
const jx = spawned.join % mw;
|
|
930
|
+
const jy = Math.floor(spawned.join / mw);
|
|
931
|
+
let nearest = spawned.join;
|
|
932
|
+
let best = Infinity;
|
|
933
|
+
for (const c of chain) {
|
|
934
|
+
const d = ((c % mw) - jx) * ((c % mw) - jx) +
|
|
935
|
+
(Math.floor(c / mw) - jy) * (Math.floor(c / mw) - jy);
|
|
936
|
+
if (d < best) {
|
|
937
|
+
best = d;
|
|
938
|
+
nearest = c;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
spawned.join = nearest;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const catchment = new Float32Array(chain.length);
|
|
945
|
+
chain.forEach((c, index) => {
|
|
946
|
+
catchment[index] =
|
|
947
|
+
chain === original
|
|
948
|
+
? acc[c]
|
|
949
|
+
: acc[original[Math.min(original.length - 1, Math.floor((index * original.length) / chain.length))]];
|
|
950
|
+
});
|
|
951
|
+
rivers.push({
|
|
952
|
+
id,
|
|
953
|
+
named: false,
|
|
954
|
+
cells: Int32Array.from(chain),
|
|
955
|
+
parent: item.parent,
|
|
956
|
+
joinCell: item.join,
|
|
957
|
+
junction: item.parent < 0 ? 'none' : 'tributary',
|
|
958
|
+
size: 0,
|
|
959
|
+
endsIn: item.ends,
|
|
960
|
+
catchment,
|
|
961
|
+
width: new Uint8Array(chain.length),
|
|
962
|
+
order: 1,
|
|
963
|
+
climb: 0,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
// ---------------------------------------------------------------------------------------------
|
|
969
|
+
// Widths and orders
|
|
970
|
+
/** Widths of declared sizes 1 to 5 at the mouth. */
|
|
971
|
+
const SIZE_WIDTH = [3, 3, 4, 6, 8, 10];
|
|
972
|
+
function computeWidths(rivers, threshold) {
|
|
973
|
+
let maximum = 1;
|
|
974
|
+
for (const river of rivers)
|
|
975
|
+
for (const value of river.catchment)
|
|
976
|
+
if (value > maximum)
|
|
977
|
+
maximum = value;
|
|
978
|
+
const minimum = Number.isFinite(threshold) ? Math.max(threshold, 4) : 8;
|
|
979
|
+
const span = log2Approx(maximum / minimum);
|
|
980
|
+
for (const river of rivers) {
|
|
981
|
+
const cells = river.cells.length;
|
|
982
|
+
let running = 3;
|
|
983
|
+
for (let index = 0; index < cells; index += 1) {
|
|
984
|
+
const ratio = river.catchment[index] / minimum;
|
|
985
|
+
let bin = span > 0.001 ? 3 + Math.floor((8 * log2Approx(ratio)) / span) : 3;
|
|
986
|
+
if (river.named && river.junction !== 'distributary') {
|
|
987
|
+
const ramp = 3 + Math.round(((SIZE_WIDTH[river.size] ?? 3) - 3) * ((index + 1) / cells));
|
|
988
|
+
if (ramp > bin)
|
|
989
|
+
bin = ramp;
|
|
990
|
+
}
|
|
991
|
+
if (bin > running)
|
|
992
|
+
running = bin;
|
|
993
|
+
river.width[index] = clamp(running, 3, 11);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// A river below a confluence is at least as wide as each river that joined it.
|
|
997
|
+
for (let index = rivers.length - 1; index >= 0; index -= 1) {
|
|
998
|
+
const child = rivers[index];
|
|
999
|
+
if (child.parent < 0 || child.junction !== 'tributary')
|
|
1000
|
+
continue;
|
|
1001
|
+
const parent = rivers[child.parent];
|
|
1002
|
+
const at = parent.cells.indexOf(child.joinCell);
|
|
1003
|
+
if (at < 0)
|
|
1004
|
+
continue;
|
|
1005
|
+
const floor = child.width[child.cells.length - 1];
|
|
1006
|
+
for (let k = at; k < parent.cells.length; k += 1)
|
|
1007
|
+
if (parent.width[k] < floor)
|
|
1008
|
+
parent.width[k] = floor;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function computeOrders(rivers) {
|
|
1012
|
+
for (let index = rivers.length - 1; index >= 0; index -= 1) {
|
|
1013
|
+
const child = rivers[index];
|
|
1014
|
+
if (child.parent < 0)
|
|
1015
|
+
continue;
|
|
1016
|
+
const parent = rivers[child.parent];
|
|
1017
|
+
if (child.order + 1 > parent.order)
|
|
1018
|
+
parent.order = child.order + 1;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
// ---------------------------------------------------------------------------------------------
|
|
1022
|
+
// Carving
|
|
1023
|
+
/** Stream-power constant: height lost per iteration is `K * sqrt(catchment) * slope`. */
|
|
1024
|
+
const STREAM_POWER = 0.06;
|
|
1025
|
+
const CARVING_STEPS = 8;
|
|
1026
|
+
function carveValleys(input) {
|
|
1027
|
+
const { macro, rivers, mw, mh } = input;
|
|
1028
|
+
const total = mw * mh;
|
|
1029
|
+
const heights = Float32Array.from(macro.heights);
|
|
1030
|
+
const before = Float32Array.from(macro.heights);
|
|
1031
|
+
for (let step = 0; step < CARVING_STEPS; step += 1)
|
|
1032
|
+
for (const river of rivers) {
|
|
1033
|
+
const cells = river.cells;
|
|
1034
|
+
for (let k = 0; k < cells.length; k += 1) {
|
|
1035
|
+
const cell = cells[k];
|
|
1036
|
+
const receiver = k + 1 < cells.length ? heights[cells[k + 1]] : LAND_FLOOR;
|
|
1037
|
+
const drop = heights[cell] - receiver;
|
|
1038
|
+
if (drop <= 0)
|
|
1039
|
+
continue;
|
|
1040
|
+
const loss = Math.min(STREAM_POWER * Math.sqrt(river.catchment[k]) * drop, 0.35 * drop);
|
|
1041
|
+
heights[cell] = Math.max(LAND_FLOOR + 0.4, heights[cell] - loss);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// Beds never rise downstream.
|
|
1045
|
+
for (const river of rivers) {
|
|
1046
|
+
let level = Infinity;
|
|
1047
|
+
for (const cell of river.cells) {
|
|
1048
|
+
level = Math.min(level, heights[cell]);
|
|
1049
|
+
heights[cell] = level;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
// Valleys and floodplains around the larger rivers: every cell takes the nearest wide river's bed.
|
|
1053
|
+
const widthAt = new Uint8Array(total);
|
|
1054
|
+
const nearest = new Int32Array(total).fill(-1);
|
|
1055
|
+
const distance = new Float32Array(total).fill(Infinity);
|
|
1056
|
+
const radiusOf = (width) => (width < 5 ? 0 : 1.5 + (width - 5));
|
|
1057
|
+
for (const river of rivers)
|
|
1058
|
+
river.cells.forEach((cell, k) => {
|
|
1059
|
+
const width = river.width[k];
|
|
1060
|
+
if (radiusOf(width) <= 0)
|
|
1061
|
+
return;
|
|
1062
|
+
widthAt[cell] = width;
|
|
1063
|
+
nearest[cell] = cell;
|
|
1064
|
+
distance[cell] = 0;
|
|
1065
|
+
});
|
|
1066
|
+
const relax = (cell, x, y, neighbour) => {
|
|
1067
|
+
const source = nearest[neighbour];
|
|
1068
|
+
if (source < 0)
|
|
1069
|
+
return;
|
|
1070
|
+
const sx = source % mw;
|
|
1071
|
+
const dx = x - sx;
|
|
1072
|
+
const dy = y - (source - sx) / mw;
|
|
1073
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
1074
|
+
if (d < distance[cell] && d <= radiusOf(widthAt[source])) {
|
|
1075
|
+
distance[cell] = d;
|
|
1076
|
+
nearest[cell] = source;
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
1080
|
+
for (let y = 0; y < mh; y += 1)
|
|
1081
|
+
for (let x = 0; x < mw; x += 1) {
|
|
1082
|
+
const cell = y * mw + x;
|
|
1083
|
+
if (x > 0)
|
|
1084
|
+
relax(cell, x, y, cell - 1);
|
|
1085
|
+
if (y > 0) {
|
|
1086
|
+
relax(cell, x, y, cell - mw);
|
|
1087
|
+
if (x > 0)
|
|
1088
|
+
relax(cell, x, y, cell - mw - 1);
|
|
1089
|
+
if (x < mw - 1)
|
|
1090
|
+
relax(cell, x, y, cell - mw + 1);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
for (let y = mh - 1; y >= 0; y -= 1)
|
|
1094
|
+
for (let x = mw - 1; x >= 0; x -= 1) {
|
|
1095
|
+
const cell = y * mw + x;
|
|
1096
|
+
if (x < mw - 1)
|
|
1097
|
+
relax(cell, x, y, cell + 1);
|
|
1098
|
+
if (y < mh - 1) {
|
|
1099
|
+
relax(cell, x, y, cell + mw);
|
|
1100
|
+
if (x < mw - 1)
|
|
1101
|
+
relax(cell, x, y, cell + mw + 1);
|
|
1102
|
+
if (x > 0)
|
|
1103
|
+
relax(cell, x, y, cell + mw - 1);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
for (let cell = 0; cell < total; cell += 1) {
|
|
1108
|
+
const source = nearest[cell];
|
|
1109
|
+
if (source < 0 || source === cell || macro.kind[cell] !== LAND)
|
|
1110
|
+
continue;
|
|
1111
|
+
const radius = radiusOf(widthAt[source]);
|
|
1112
|
+
const d = distance[cell];
|
|
1113
|
+
const flat = 0.45 * radius;
|
|
1114
|
+
const profile = d <= flat ? 0.12 * d : 0.12 * flat + 1.1 * (d - flat);
|
|
1115
|
+
const target = heights[source] + profile;
|
|
1116
|
+
if (target < heights[cell]) {
|
|
1117
|
+
const weight = 1 - smoothstep((d - 0.6 * radius) / (0.4 * radius));
|
|
1118
|
+
heights[cell] = heights[cell] + (target - heights[cell]) * weight;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
const valley = new Uint8Array(total);
|
|
1122
|
+
for (let cell = 0; cell < total; cell += 1)
|
|
1123
|
+
if (macro.kind[cell] === LAND)
|
|
1124
|
+
valley[cell] = clamp(Math.round((before[cell] - heights[cell]) * 10), 0, 255);
|
|
1125
|
+
void SQRT2;
|
|
1126
|
+
return { heights, valley };
|
|
1127
|
+
}
|
|
1128
|
+
// ---------------------------------------------------------------------------------------------
|
|
1129
|
+
/**
|
|
1130
|
+
* Run the hydrology of the model on the macro relief. The macro heights are copied, never
|
|
1131
|
+
* modified in place.
|
|
1132
|
+
*/
|
|
1133
|
+
export function synthesizeHydrology(input) {
|
|
1134
|
+
const { geography, macro, seed } = input;
|
|
1135
|
+
const mw = macro.width;
|
|
1136
|
+
const mh = macro.height;
|
|
1137
|
+
const total = mw * mh;
|
|
1138
|
+
const kind = macro.kind;
|
|
1139
|
+
const notes = [];
|
|
1140
|
+
const rain = input.rainfall ?? new Float32Array(total).fill(1);
|
|
1141
|
+
const heap = new MinHeap(total);
|
|
1142
|
+
// Conditioned heights: a tiny hash jitter breaks ties so flat ground does not drain in straight lines.
|
|
1143
|
+
const jitterSeed = seedFromString(seed, 'hydrology-jitter');
|
|
1144
|
+
const conditioned = new Float32Array(total);
|
|
1145
|
+
for (let cell = 0; cell < total; cell += 1) {
|
|
1146
|
+
if (!isLandCell(kind, cell))
|
|
1147
|
+
continue;
|
|
1148
|
+
const x = cell % mw;
|
|
1149
|
+
conditioned[cell] =
|
|
1150
|
+
macro.heights[cell] + (hash01(jitterSeed, x, (cell - x) / mw) - 0.5) * 0.03;
|
|
1151
|
+
}
|
|
1152
|
+
const first = priorityFlood(conditioned, kind, mw, mh, undefined, heap);
|
|
1153
|
+
input.signal?.throwIfAborted();
|
|
1154
|
+
// Lakes by cell, for sources and mouths.
|
|
1155
|
+
const lakeOf = new Int16Array(total);
|
|
1156
|
+
if (geography.lakes.length > 0) {
|
|
1157
|
+
const mask = new Int16Array(total);
|
|
1158
|
+
geography.lakes.forEach((lake, index) => {
|
|
1159
|
+
fillAreaStrip(lake.area, { width: mw, height: mh }, { y0: 0, y1: mh }, mask, index + 1);
|
|
1160
|
+
const x0 = Math.max(0, Math.floor(lake.area.bounds.x0 * mw) - 1);
|
|
1161
|
+
const x1 = Math.min(mw - 1, Math.ceil(lake.area.bounds.x1 * mw) + 1);
|
|
1162
|
+
const y0 = Math.max(0, Math.floor(lake.area.bounds.y0 * mh) - 1);
|
|
1163
|
+
const y1 = Math.min(mh - 1, Math.ceil(lake.area.bounds.y1 * mh) + 1);
|
|
1164
|
+
for (let y = y0; y <= y1; y += 1) {
|
|
1165
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
1166
|
+
const cell = y * mw + x;
|
|
1167
|
+
if (mask[cell] !== 0 && kind[cell] === LAKE)
|
|
1168
|
+
lakeOf[cell] = index + 1;
|
|
1169
|
+
}
|
|
1170
|
+
mask.fill(0, y * mw + x0, y * mw + x1 + 1);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
// Named rivers, parents first, each as a least-cost route over the conditioned heights.
|
|
1175
|
+
const occupant = new Int16Array(total);
|
|
1176
|
+
const wander = new Float32Array(total);
|
|
1177
|
+
const wanderSeed = seedFromString(seed, 'river-wander');
|
|
1178
|
+
for (let cell = 0; cell < total; cell += 1) {
|
|
1179
|
+
const x = cell % mw;
|
|
1180
|
+
const y = (cell - x) / mw;
|
|
1181
|
+
wander[cell] =
|
|
1182
|
+
0.55 * noise2(wanderSeed, x / 22, y / 22) + 0.45 * noise2(wanderSeed + 1, x / 7, y / 7);
|
|
1183
|
+
}
|
|
1184
|
+
const router = createRouter(first.filled, kind, occupant, wander, mw, mh, heap);
|
|
1185
|
+
const named = routeNamedRivers({
|
|
1186
|
+
geography,
|
|
1187
|
+
macro,
|
|
1188
|
+
router,
|
|
1189
|
+
occupant,
|
|
1190
|
+
lakeOf,
|
|
1191
|
+
flood: first,
|
|
1192
|
+
conditioned,
|
|
1193
|
+
notes,
|
|
1194
|
+
seed,
|
|
1195
|
+
signal: input.signal,
|
|
1196
|
+
});
|
|
1197
|
+
// Burn the channels into the heights and flood again with the channels as outlets, so every
|
|
1198
|
+
// other cell drains either to the coast or into a channel.
|
|
1199
|
+
const channel = new Uint8Array(total);
|
|
1200
|
+
const burned = Float32Array.from(conditioned);
|
|
1201
|
+
for (const route of named) {
|
|
1202
|
+
let level = Infinity;
|
|
1203
|
+
for (const cell of route.cells) {
|
|
1204
|
+
level = Math.min(conditioned[cell] - 0.4, level - 0.003);
|
|
1205
|
+
burned[cell] = level;
|
|
1206
|
+
channel[cell] = 1;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
input.signal?.throwIfAborted();
|
|
1210
|
+
const second = priorityFlood(burned, kind, mw, mh, channel, heap);
|
|
1211
|
+
input.signal?.throwIfAborted();
|
|
1212
|
+
const acc = accumulate(second, rain, kind);
|
|
1213
|
+
// Catchment along every named channel: its own cells, the cells draining into them and the
|
|
1214
|
+
// tributaries that join.
|
|
1215
|
+
const catchments = new Map();
|
|
1216
|
+
for (const route of [...named].reverse()) {
|
|
1217
|
+
const values = new Float32Array(route.cells.length);
|
|
1218
|
+
let sum = 0;
|
|
1219
|
+
route.cells.forEach((cell, index) => {
|
|
1220
|
+
sum += acc[cell];
|
|
1221
|
+
for (const other of named)
|
|
1222
|
+
if (other.parentId === route.river.id && other.joinCell === cell) {
|
|
1223
|
+
const child = catchments.get(other.river.id);
|
|
1224
|
+
if (child !== undefined)
|
|
1225
|
+
sum += child[child.length - 1];
|
|
1226
|
+
}
|
|
1227
|
+
values[index] = sum;
|
|
1228
|
+
});
|
|
1229
|
+
catchments.set(route.river.id, values);
|
|
1230
|
+
}
|
|
1231
|
+
// The accumulation network.
|
|
1232
|
+
const namedPixels = named.reduce((sum, route) => sum + route.cells.length * PIXELS_PER_CELL, 0);
|
|
1233
|
+
const density = clamp(input.density ?? DEFAULT_RIVER_DENSITY, 0, 0.05);
|
|
1234
|
+
const budget = Math.max(0, Math.min(RIVER_PIXEL_CAP, density * input.landPixels) - namedPixels);
|
|
1235
|
+
const stream = selectStreams({
|
|
1236
|
+
flood: second,
|
|
1237
|
+
acc,
|
|
1238
|
+
kind,
|
|
1239
|
+
channel,
|
|
1240
|
+
budgetCells: budget / PIXELS_PER_CELL,
|
|
1241
|
+
count: input.count,
|
|
1242
|
+
mw,
|
|
1243
|
+
mh,
|
|
1244
|
+
});
|
|
1245
|
+
const rivers = [];
|
|
1246
|
+
const riverIndex = new Map();
|
|
1247
|
+
for (const route of named) {
|
|
1248
|
+
const parent = route.parentId === undefined ? -1 : (riverIndex.get(route.parentId) ?? -1);
|
|
1249
|
+
riverIndex.set(route.river.id, rivers.length);
|
|
1250
|
+
rivers.push({
|
|
1251
|
+
id: route.river.id,
|
|
1252
|
+
...(route.river.name === undefined ? {} : { name: route.river.name }),
|
|
1253
|
+
named: true,
|
|
1254
|
+
cells: Int32Array.from(route.cells),
|
|
1255
|
+
parent,
|
|
1256
|
+
joinCell: route.joinCell,
|
|
1257
|
+
junction: route.parentId === undefined ? 'none' : 'tributary',
|
|
1258
|
+
size: route.river.size,
|
|
1259
|
+
endsIn: route.endsIn,
|
|
1260
|
+
catchment: catchments.get(route.river.id),
|
|
1261
|
+
width: new Uint8Array(route.cells.length),
|
|
1262
|
+
order: 1,
|
|
1263
|
+
climb: route.climb,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
addDeltas(rivers, named, geography, router, occupant, kind, mw, mh, notes);
|
|
1267
|
+
decomposeStreams({
|
|
1268
|
+
stream,
|
|
1269
|
+
flood: second,
|
|
1270
|
+
acc,
|
|
1271
|
+
channel,
|
|
1272
|
+
named,
|
|
1273
|
+
rivers,
|
|
1274
|
+
riverIndex,
|
|
1275
|
+
occupant,
|
|
1276
|
+
kind,
|
|
1277
|
+
height: conditioned,
|
|
1278
|
+
seed,
|
|
1279
|
+
mw,
|
|
1280
|
+
mh,
|
|
1281
|
+
});
|
|
1282
|
+
computeWidths(rivers, stream.threshold);
|
|
1283
|
+
computeOrders(rivers);
|
|
1284
|
+
input.signal?.throwIfAborted();
|
|
1285
|
+
const carved = carveValleys({ macro, rivers, mw, mh });
|
|
1286
|
+
let estimated = 0;
|
|
1287
|
+
for (const river of rivers)
|
|
1288
|
+
estimated += river.cells.length * PIXELS_PER_CELL;
|
|
1289
|
+
if (estimated > RIVER_PIXEL_CAP * 1.15)
|
|
1290
|
+
notes.push({
|
|
1291
|
+
code: 'MAP_RIVER_DENSITY',
|
|
1292
|
+
severity: 'warning',
|
|
1293
|
+
message: `The river network is estimated at ${Math.round(estimated)} pixels, above the ${RIVER_PIXEL_CAP} guard; the named rivers alone are this long`,
|
|
1294
|
+
details: { estimatedPixels: Math.round(estimated) },
|
|
1295
|
+
});
|
|
1296
|
+
const endorheic = geography.lakes.filter((lake) => lake.outflowId === undefined &&
|
|
1297
|
+
geography.rivers.every((river) => riverSourceLake(river) !== lake.id));
|
|
1298
|
+
if (endorheic.length > 0)
|
|
1299
|
+
notes.push({
|
|
1300
|
+
code: 'MAP_LAKE_ENDORHEIC',
|
|
1301
|
+
severity: 'info',
|
|
1302
|
+
message: `${endorheic.length} lake(s) have no outflow river and keep their water: ${endorheic
|
|
1303
|
+
.slice(0, 8)
|
|
1304
|
+
.map((lake) => lake.id)
|
|
1305
|
+
.join(', ')}`,
|
|
1306
|
+
details: { lakes: endorheic.map((lake) => lake.id).slice(0, 50) },
|
|
1307
|
+
});
|
|
1308
|
+
return {
|
|
1309
|
+
heights: carved.heights,
|
|
1310
|
+
rivers,
|
|
1311
|
+
valley: carved.valley,
|
|
1312
|
+
estimatedPixels: estimated,
|
|
1313
|
+
notes,
|
|
1314
|
+
};
|
|
1315
|
+
}
|