hoi4-agent-tools 4.4.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- 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/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/border-quality.js +25 -7
- package/dist/hoi4_agent_tools/map/derived.js +16 -3
- package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
- package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
- package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
- package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
- package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
- package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
- package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
- package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
- package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
- package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
- package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
- package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
- package/dist/hoi4_agent_tools/map/model.js +2 -1
- package/dist/hoi4_agent_tools/map/operations.js +282 -38
- package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
- package/dist/hoi4_agent_tools/map/service.js +93 -16
- package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
- package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
- package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
- package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
- package/dist/hoi4_agent_tools/map/territory.js +1108 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
- package/dist/hoi4_agent_tools/map/validation.js +4 -0
- package/dist/hoi4_agent_tools/map/world-context.js +23 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +400 -145
- package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
- package/dist/hoi4_agent_tools/schemas/map.js +277 -34
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/focus.md +6 -0
- package/docs/map.md +132 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- 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 +13 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- 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 +1541 -786
- 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 +1 -1
- 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 +1 -1
- 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
|
@@ -255,6 +255,58 @@ function climbOf(height, cells) {
|
|
|
255
255
|
}
|
|
256
256
|
return climb;
|
|
257
257
|
}
|
|
258
|
+
/** The cell where a route rises most above its lowest earlier point: the divide it must cross. */
|
|
259
|
+
function climbDivide(height, cells) {
|
|
260
|
+
let lowest = Infinity;
|
|
261
|
+
let climb = 0;
|
|
262
|
+
let divide = cells[0] ?? 0;
|
|
263
|
+
for (const cell of cells) {
|
|
264
|
+
const value = height[cell];
|
|
265
|
+
if (value < lowest)
|
|
266
|
+
lowest = value;
|
|
267
|
+
else if (value - lowest > climb) {
|
|
268
|
+
climb = value - lowest;
|
|
269
|
+
divide = cell;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { climb, cell: divide };
|
|
273
|
+
}
|
|
274
|
+
/** A model range running close to a point, in map-width units, or undefined when none does. */
|
|
275
|
+
function nearestRange(geography, x, y, aspect) {
|
|
276
|
+
let best;
|
|
277
|
+
for (const range of geography.ranges) {
|
|
278
|
+
const { xs, ys } = range.path;
|
|
279
|
+
for (let index = 0; index < xs.length; index += 1) {
|
|
280
|
+
const next = index + 1 < xs.length ? index + 1 : index;
|
|
281
|
+
const ax = xs[index];
|
|
282
|
+
const ay = ys[index] * aspect;
|
|
283
|
+
const bx = xs[next];
|
|
284
|
+
const by = ys[next] * aspect;
|
|
285
|
+
const px = x - ax;
|
|
286
|
+
const py = y * aspect - ay;
|
|
287
|
+
const dx = bx - ax;
|
|
288
|
+
const dy = by - ay;
|
|
289
|
+
const length = dx * dx + dy * dy;
|
|
290
|
+
const t = length === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / length));
|
|
291
|
+
const distance = Math.hypot(px - t * dx, py - t * dy);
|
|
292
|
+
if (best === undefined ||
|
|
293
|
+
distance < best.distance ||
|
|
294
|
+
(distance === best.distance && range.id < best.id))
|
|
295
|
+
best = { id: range.id, distance };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return best;
|
|
299
|
+
}
|
|
300
|
+
/** The volcano nearest a point, and whether the point lies on its cone (within 1.3 radii). */
|
|
301
|
+
function nearestVolcano(geography, x, y, aspect) {
|
|
302
|
+
let best;
|
|
303
|
+
for (const volcano of geography.volcanoes) {
|
|
304
|
+
const distance = Math.hypot(x - volcano.at.x, (y - volcano.at.y) * aspect);
|
|
305
|
+
if (best === undefined || distance < best.distance)
|
|
306
|
+
best = { id: volcano.id, distance, inside: distance <= volcano.radius * 1.3 };
|
|
307
|
+
}
|
|
308
|
+
return best;
|
|
309
|
+
}
|
|
258
310
|
// ---------------------------------------------------------------------------------------------
|
|
259
311
|
function fail(code, message, details) {
|
|
260
312
|
throw new ServiceError(code, message, details);
|
|
@@ -412,8 +464,11 @@ function routeNamedRivers(context) {
|
|
|
412
464
|
const done = new Set();
|
|
413
465
|
const pending = [...geography.rivers].sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
414
466
|
const lakeIndex = new Map(geography.lakes.map((lake, index) => [lake.id, index + 1]));
|
|
467
|
+
// Every river that cannot reach its mouth is collected, so one run shows all of them.
|
|
468
|
+
const failures = [];
|
|
469
|
+
const failed = new Set();
|
|
415
470
|
while (pending.length > 0) {
|
|
416
|
-
const ready = pending.filter((river) => river.mouth.type !== 'river' || done.has(river.mouth.id));
|
|
471
|
+
const ready = pending.filter((river) => river.mouth.type !== 'river' || done.has(river.mouth.id) || failed.has(river.mouth.id));
|
|
417
472
|
if (ready.length === 0)
|
|
418
473
|
fail('MAP_RIVER_CYCLE', 'Rivers flow into each other in a cycle', {
|
|
419
474
|
rivers: pending.map((river) => river.id).slice(0, 20),
|
|
@@ -422,213 +477,282 @@ function routeNamedRivers(context) {
|
|
|
422
477
|
context.signal?.throwIfAborted();
|
|
423
478
|
pending.splice(pending.indexOf(river), 1);
|
|
424
479
|
const ownId = routed.length + 1;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
480
|
+
if (river.mouth.type === 'river' && failed.has(river.mouth.id)) {
|
|
481
|
+
failed.add(river.id);
|
|
482
|
+
failures.push({
|
|
483
|
+
riverId: river.id,
|
|
484
|
+
reason: `its mouth river ${river.mouth.id} cannot be routed`,
|
|
485
|
+
blockedBy: river.mouth.id,
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
428
488
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
for (let
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
489
|
+
try {
|
|
490
|
+
let start;
|
|
491
|
+
function unreachable(reason, details = {}) {
|
|
492
|
+
return fail('MAP_RIVER_UNREACHABLE_MOUTH', `River ${river.id} cannot reach its mouth: ${reason}`, { riverId: river.id, ...details });
|
|
493
|
+
}
|
|
494
|
+
// The first via point or the mouth point steers the choice of a lake's outlet.
|
|
495
|
+
const firstVia = river.via[0];
|
|
496
|
+
if (river.source.type === 'lake') {
|
|
497
|
+
const index = lakeIndex.get(river.source.id);
|
|
498
|
+
const lake = geography.get('lake', river.source.id);
|
|
499
|
+
const x0 = Math.max(0, Math.floor(lake.area.bounds.x0 * mw) - 2);
|
|
500
|
+
const x1 = Math.min(mw - 1, Math.ceil(lake.area.bounds.x1 * mw) + 2);
|
|
501
|
+
const y0 = Math.max(0, Math.floor(lake.area.bounds.y0 * mh) - 2);
|
|
502
|
+
const y1 = Math.min(mh - 1, Math.ceil(lake.area.bounds.y1 * mh) + 2);
|
|
503
|
+
let best = -1;
|
|
504
|
+
let bestScore = Infinity;
|
|
505
|
+
for (let y = y0; y <= y1; y += 1)
|
|
506
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
507
|
+
const cell = y * mw + x;
|
|
508
|
+
if (!isLandCell(kind, cell) || occupant[cell] !== 0)
|
|
509
|
+
continue;
|
|
510
|
+
let rim = false;
|
|
511
|
+
for (let n = 0; n < 8 && !rim; n += 1) {
|
|
512
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
513
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
514
|
+
if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && lakeOf[ny * mw + nx] === index)
|
|
515
|
+
rim = true;
|
|
516
|
+
}
|
|
517
|
+
if (!rim)
|
|
518
|
+
continue;
|
|
519
|
+
const score = firstVia === undefined
|
|
520
|
+
? conditioned[cell]
|
|
521
|
+
: (x + 0.5 - firstVia.x * mw) * (x + 0.5 - firstVia.x * mw) +
|
|
522
|
+
(y + 0.5 - firstVia.y * mh) * (y + 0.5 - firstVia.y * mh);
|
|
523
|
+
if (score < bestScore) {
|
|
524
|
+
bestScore = score;
|
|
525
|
+
best = cell;
|
|
526
|
+
}
|
|
461
527
|
}
|
|
528
|
+
if (best < 0)
|
|
529
|
+
unreachable(`its source lake ${river.source.id} has no free shore`);
|
|
530
|
+
start = best;
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
if (river.source.type !== 'point')
|
|
534
|
+
return fail('MAP_RIVER_SOURCE_NOT_LAND', `River ${river.id} must start at a point or a lake`, {
|
|
535
|
+
riverId: river.id,
|
|
536
|
+
});
|
|
537
|
+
start = landCellAt(macro, river.source.x, river.source.y, 8);
|
|
538
|
+
if (start < 0)
|
|
539
|
+
fail('MAP_RIVER_SOURCE_NOT_LAND', `The source of river ${river.id} has no land within reach`, {
|
|
540
|
+
riverId: river.id,
|
|
541
|
+
});
|
|
542
|
+
if (occupant[start] !== 0) {
|
|
543
|
+
let free = -1;
|
|
544
|
+
for (let r = 1; r <= 3 && free < 0; r += 1)
|
|
545
|
+
for (let dy = -r; dy <= r && free < 0; dy += 1)
|
|
546
|
+
for (let dx = -r; dx <= r && free < 0; dx += 1) {
|
|
547
|
+
const x = (start % mw) + dx;
|
|
548
|
+
const y = Math.floor(start / mw) + dy;
|
|
549
|
+
if (x < 0 || y < 0 || x >= mw || y >= mh)
|
|
550
|
+
continue;
|
|
551
|
+
const cell = y * mw + x;
|
|
552
|
+
if (isLandCell(kind, cell) && occupant[cell] === 0)
|
|
553
|
+
free = cell;
|
|
554
|
+
}
|
|
555
|
+
if (free < 0)
|
|
556
|
+
unreachable('its source is already taken by another river');
|
|
557
|
+
start = free;
|
|
462
558
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
start =
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
559
|
+
}
|
|
560
|
+
const cells = [start];
|
|
561
|
+
occupant[start] = ownId;
|
|
562
|
+
let current = start;
|
|
563
|
+
const append = (leg) => {
|
|
564
|
+
for (let index = 1; index < leg.length; index += 1) {
|
|
565
|
+
cells.push(leg[index]);
|
|
566
|
+
occupant[leg[index]] = ownId;
|
|
567
|
+
}
|
|
568
|
+
current = cells[cells.length - 1];
|
|
569
|
+
};
|
|
570
|
+
for (const via of river.via) {
|
|
571
|
+
const target = landCellAt(macro, via.x, via.y, 6);
|
|
572
|
+
if (target < 0 || target === current || occupant[target] !== 0)
|
|
573
|
+
continue;
|
|
574
|
+
const leg = router.route({ start: current, goalCell: target });
|
|
575
|
+
if (leg === undefined)
|
|
576
|
+
unreachable('a via point cannot be reached over land', { via: { x: via.x, y: via.y } });
|
|
577
|
+
append(leg.cells);
|
|
578
|
+
}
|
|
579
|
+
let joinCell = -1;
|
|
580
|
+
let parentId;
|
|
581
|
+
let endsIn = 'sea';
|
|
582
|
+
const mouth = river.mouth;
|
|
583
|
+
let final;
|
|
584
|
+
// A river that has run a while keeps its general direction toward an open "sea" mouth.
|
|
585
|
+
let bias;
|
|
586
|
+
if (cells.length >= 12) {
|
|
587
|
+
const dx = (current % mw) - (start % mw);
|
|
588
|
+
const dy = Math.floor(current / mw) - Math.floor(start / mw);
|
|
589
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
590
|
+
if (length >= 8)
|
|
591
|
+
bias = { dx: dx / length, dy: dy / length, weight: 1.5 };
|
|
592
|
+
}
|
|
593
|
+
if (mouth.type === 'sea') {
|
|
594
|
+
final = router.route({
|
|
595
|
+
start: current,
|
|
596
|
+
goalTest: (cell) => touchesWater(kind, mw, mh, cell, SEA) || onEdge(mw, mh, cell) ? -1 : undefined,
|
|
597
|
+
...(bias === undefined ? {} : { bias }),
|
|
471
598
|
});
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
599
|
+
if (final === undefined)
|
|
600
|
+
unreachable(`no coast of ${mouth.id} lies on its land`);
|
|
601
|
+
}
|
|
602
|
+
else if (mouth.type === 'lake') {
|
|
603
|
+
const index = lakeIndex.get(mouth.id);
|
|
604
|
+
final = router.route({
|
|
605
|
+
start: current,
|
|
606
|
+
goalTest: (cell) => {
|
|
607
|
+
const x = cell % mw;
|
|
608
|
+
const y = (cell - x) / mw;
|
|
609
|
+
for (let n = 0; n < 8; n += 1) {
|
|
610
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
611
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
612
|
+
if (nx >= 0 && ny >= 0 && nx < mw && ny < mh && lakeOf[ny * mw + nx] === index)
|
|
613
|
+
return -1;
|
|
614
|
+
}
|
|
615
|
+
return undefined;
|
|
616
|
+
},
|
|
476
617
|
});
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
618
|
+
if (final === undefined)
|
|
619
|
+
unreachable(`lake ${mouth.id} is not reachable over land`);
|
|
620
|
+
endsIn = 'lake';
|
|
621
|
+
}
|
|
622
|
+
else if (mouth.type === 'river') {
|
|
623
|
+
const parentIndex = routed.findIndex((route) => route.river.id === mouth.id);
|
|
624
|
+
const parentOwner = parentIndex + 1;
|
|
625
|
+
final = router.route({
|
|
626
|
+
start: current,
|
|
627
|
+
goalTest: (cell) => {
|
|
628
|
+
const x = cell % mw;
|
|
629
|
+
const y = (cell - x) / mw;
|
|
630
|
+
for (let n = 0; n < 8; n += 1) {
|
|
631
|
+
const nx = x + NEIGHBOUR_DX[n];
|
|
632
|
+
const ny = y + NEIGHBOUR_DY[n];
|
|
633
|
+
if (nx < 0 || ny < 0 || nx >= mw || ny >= mh)
|
|
485
634
|
continue;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
free = cell;
|
|
635
|
+
if (occupant[ny * mw + nx] === parentOwner)
|
|
636
|
+
return ny * mw + nx;
|
|
489
637
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
638
|
+
return undefined;
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
if (final === undefined)
|
|
642
|
+
unreachable(`river ${mouth.id} is not reachable over land`);
|
|
643
|
+
parentId = mouth.id;
|
|
644
|
+
joinCell = final.joined;
|
|
645
|
+
endsIn = 'river';
|
|
493
646
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
647
|
+
else {
|
|
648
|
+
const target = landCellAt(macro, mouth.x, mouth.y, 8);
|
|
649
|
+
if (target < 0)
|
|
650
|
+
unreachable('its mouth lies far from any land');
|
|
651
|
+
final =
|
|
652
|
+
target === current
|
|
653
|
+
? { cells: [current], joined: -1 }
|
|
654
|
+
: router.route({ start: current, goalCell: target });
|
|
655
|
+
if (final === undefined)
|
|
656
|
+
unreachable('its mouth cannot be reached over land');
|
|
502
657
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
658
|
+
append(final.cells);
|
|
659
|
+
if (mouth.type === 'point') {
|
|
660
|
+
// Past a mouth drawn on land, the river follows the conditioned flow to the water.
|
|
661
|
+
let guard = 4096;
|
|
662
|
+
while (guard-- > 0 &&
|
|
663
|
+
!touchesWater(kind, mw, mh, current, SEA) &&
|
|
664
|
+
!touchesWater(kind, mw, mh, current, LAKE) &&
|
|
665
|
+
!onEdge(mw, mh, current)) {
|
|
666
|
+
const next = flood.receiver[current];
|
|
667
|
+
if (next < 0 || occupant[next] !== 0)
|
|
668
|
+
break;
|
|
669
|
+
cells.push(next);
|
|
670
|
+
occupant[next] = ownId;
|
|
671
|
+
current = next;
|
|
672
|
+
}
|
|
673
|
+
endsIn = touchesWater(kind, mw, mh, current, LAKE)
|
|
674
|
+
? 'lake'
|
|
675
|
+
: touchesWater(kind, mw, mh, current, SEA)
|
|
676
|
+
? 'sea'
|
|
677
|
+
: 'edge';
|
|
678
|
+
}
|
|
679
|
+
else if (mouth.type === 'sea')
|
|
680
|
+
endsIn = touchesWater(kind, mw, mh, current, SEA) ? 'sea' : 'edge';
|
|
681
|
+
cells.splice(0, cells.length, ...meanderRoute(cells, ownId, { occupant, kind, height: conditioned, mw, mh, seed: context.seed }, river.id));
|
|
682
|
+
const climb = climbOf(conditioned, cells);
|
|
683
|
+
// A drawn river is authoritative over the relief: where its course rises over a divide, the
|
|
684
|
+
// bed is burned to a monotone descent below, which cuts a gorge through the cone, foothill
|
|
685
|
+
// or range slope (ADR 0071). Each such crossing is reported with its location.
|
|
686
|
+
if (climb > MAXIMUM_RIVER_CLIMB) {
|
|
687
|
+
const divide = climbDivide(conditioned, cells);
|
|
688
|
+
const cx = (divide.cell % mw) + 0.5;
|
|
689
|
+
const cy = Math.floor(divide.cell / mw) + 0.5;
|
|
690
|
+
const aspect = mh / mw;
|
|
691
|
+
const range = nearestRange(geography, cx / mw, cy / mh, aspect);
|
|
692
|
+
const volcano = nearestVolcano(geography, cx / mw, cy / mh, aspect);
|
|
693
|
+
notes.push({
|
|
694
|
+
code: 'MAP_RIVER_CARVED_CROSSING',
|
|
695
|
+
severity: 'warning',
|
|
696
|
+
message: `River ${river.id} crosses a divide ${Math.round(climb)} height units above its bed near (${Math.floor(cx * 2)}, ${Math.floor(cy * 2)}); its valley is carved through it${volcano?.inside === true
|
|
697
|
+
? ` (the slope of volcano ${volcano.id})`
|
|
698
|
+
: range !== undefined && range.distance <= 0.06
|
|
699
|
+
? ` (the range ${range.id})`
|
|
700
|
+
: ''}`,
|
|
701
|
+
details: {
|
|
702
|
+
riverId: river.id,
|
|
703
|
+
climb: Math.round(climb),
|
|
704
|
+
limit: MAXIMUM_RIVER_CLIMB,
|
|
705
|
+
divide: {
|
|
706
|
+
x: Math.floor(cx * 2),
|
|
707
|
+
y: Math.floor(cy * 2),
|
|
708
|
+
height: Math.round(conditioned[divide.cell]),
|
|
709
|
+
...(range === undefined ? {} : { nearestRange: range.id }),
|
|
710
|
+
...(range !== undefined && range.distance <= 0.06
|
|
711
|
+
? { range: range.id, rangeDistance: Math.round(range.distance * 1000) / 1000 }
|
|
712
|
+
: {}),
|
|
713
|
+
...(volcano?.inside === true ? { volcano: volcano.id } : {}),
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
routed.push({
|
|
719
|
+
river,
|
|
720
|
+
cells,
|
|
721
|
+
...(parentId === undefined ? {} : { parentId }),
|
|
722
|
+
joinCell,
|
|
723
|
+
endsIn,
|
|
724
|
+
climb,
|
|
533
725
|
});
|
|
534
|
-
|
|
535
|
-
unreachable(`no coast of ${mouth.id} lies on its land`);
|
|
726
|
+
done.add(river.id);
|
|
536
727
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
return undefined;
|
|
551
|
-
},
|
|
728
|
+
catch (error) {
|
|
729
|
+
if (!(error instanceof ServiceError) || error.code !== 'MAP_RIVER_UNREACHABLE_MOUTH')
|
|
730
|
+
throw error;
|
|
731
|
+
// Release what the failed route took so later rivers see the ground free.
|
|
732
|
+
for (let cell = 0; cell < occupant.length; cell += 1)
|
|
733
|
+
if (occupant[cell] === ownId)
|
|
734
|
+
occupant[cell] = 0;
|
|
735
|
+
failed.add(river.id);
|
|
736
|
+
failures.push({
|
|
737
|
+
riverId: river.id,
|
|
738
|
+
reason: error.message.replace(`River ${river.id} cannot reach its mouth: `, ''),
|
|
739
|
+
...error.details,
|
|
552
740
|
});
|
|
553
|
-
if (final === undefined)
|
|
554
|
-
unreachable(`lake ${mouth.id} is not reachable over land`);
|
|
555
|
-
endsIn = 'lake';
|
|
556
741
|
}
|
|
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
742
|
}
|
|
630
743
|
}
|
|
631
744
|
void notes;
|
|
745
|
+
if (failures.length > 0) {
|
|
746
|
+
const first = failures[0];
|
|
747
|
+
fail('MAP_RIVER_UNREACHABLE_MOUTH', failures.length === 1
|
|
748
|
+
? `River ${first.riverId} cannot reach its mouth: ${first.reason}`
|
|
749
|
+
: `${failures.length} rivers cannot reach their mouths: ${failures
|
|
750
|
+
.slice(0, 12)
|
|
751
|
+
.map(({ riverId }) => riverId)
|
|
752
|
+
.join(', ')}${failures.length > 12 ? ', ...' : ''}; the details list each reason and divide`,
|
|
753
|
+
// The first failure keeps the fields earlier releases returned for a single river.
|
|
754
|
+
{ ...first, rivers: failures });
|
|
755
|
+
}
|
|
632
756
|
return routed;
|
|
633
757
|
}
|
|
634
758
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -85,6 +85,49 @@ function barrierField(width, height, kind, heights, riverBitmap, macro, signal)
|
|
|
85
85
|
}
|
|
86
86
|
return { width, height, strength, sources };
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Run the macro relief and the hydrology only, which is where a model's rivers fail to reach
|
|
90
|
+
* their mouths, and return their notes. The full-resolution heights, river pixels and fields are
|
|
91
|
+
* not built, so a model can be checked in a fraction of a write. A river that cannot reach its
|
|
92
|
+
* mouth throws as in `synthesizeTerrain`, with every failing river in its details.
|
|
93
|
+
*/
|
|
94
|
+
export function checkTerrainRivers(input) {
|
|
95
|
+
const { geography, width, height, seed, kind } = input;
|
|
96
|
+
const relief = { ...DEFAULT_RELIEF, ...input.relief };
|
|
97
|
+
let landPixels = 0;
|
|
98
|
+
for (let index = 0; index < width * height; index += 1)
|
|
99
|
+
if (kind[index] === LAND)
|
|
100
|
+
landPixels += 1;
|
|
101
|
+
const macro = synthesizeMacroRelief({
|
|
102
|
+
geography,
|
|
103
|
+
width,
|
|
104
|
+
height,
|
|
105
|
+
seed,
|
|
106
|
+
kind,
|
|
107
|
+
relief,
|
|
108
|
+
signal: input.signal,
|
|
109
|
+
});
|
|
110
|
+
const adjust = input.adjustHeight;
|
|
111
|
+
if (adjust !== undefined)
|
|
112
|
+
for (let y = 0; y < macro.height; y += 1)
|
|
113
|
+
for (let x = 0; x < macro.width; x += 1) {
|
|
114
|
+
const cell = y * macro.width + x;
|
|
115
|
+
if (macro.kind[cell] === LAND)
|
|
116
|
+
macro.heights[cell] = adjust(2 * x + 1, 2 * y + 1, macro.heights[cell]);
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
...synthesizeHydrology({
|
|
120
|
+
geography,
|
|
121
|
+
macro,
|
|
122
|
+
seed,
|
|
123
|
+
density: input.rivers?.density,
|
|
124
|
+
count: input.rivers?.count,
|
|
125
|
+
rainfall: input.rainfall,
|
|
126
|
+
landPixels,
|
|
127
|
+
signal: input.signal,
|
|
128
|
+
}).notes,
|
|
129
|
+
];
|
|
130
|
+
}
|
|
88
131
|
/** Build relief, hydrology, normals and fields for a geography model on a map of the given size. */
|
|
89
132
|
export function synthesizeTerrain(input) {
|
|
90
133
|
const { geography, width, height, seed, kind } = input;
|