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.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
  3. package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
  4. package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
  5. package/dist/hoi4_agent_tools/focus/drift.js +34 -0
  6. package/dist/hoi4_agent_tools/focus/service.js +9 -3
  7. package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
  8. package/dist/hoi4_agent_tools/map/border-quality.js +25 -7
  9. package/dist/hoi4_agent_tools/map/derived.js +16 -3
  10. package/dist/hoi4_agent_tools/map/gameplay/crossing-validation.js +268 -0
  11. package/dist/hoi4_agent_tools/map/gameplay/crossings.js +961 -0
  12. package/dist/hoi4_agent_tools/map/gameplay/development.js +168 -0
  13. package/dist/hoi4_agent_tools/map/gameplay/economy.js +75 -0
  14. package/dist/hoi4_agent_tools/map/gameplay/facts.js +264 -0
  15. package/dist/hoi4_agent_tools/map/gameplay/inland-sea.js +66 -0
  16. package/dist/hoi4_agent_tools/map/gameplay/metrics.js +132 -0
  17. package/dist/hoi4_agent_tools/map/gameplay/names.js +419 -0
  18. package/dist/hoi4_agent_tools/map/gameplay/ports.js +244 -0
  19. package/dist/hoi4_agent_tools/map/gameplay/prepare.js +27 -0
  20. package/dist/hoi4_agent_tools/map/gameplay/rail-cost.js +77 -0
  21. package/dist/hoi4_agent_tools/map/gameplay/sites.js +61 -0
  22. package/dist/hoi4_agent_tools/map/geography/resolve.js +15 -4
  23. package/dist/hoi4_agent_tools/map/model.js +2 -1
  24. package/dist/hoi4_agent_tools/map/operations.js +282 -38
  25. package/dist/hoi4_agent_tools/map/reference-preparation.js +4 -1
  26. package/dist/hoi4_agent_tools/map/relief/hydrology.js +316 -192
  27. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +43 -0
  28. package/dist/hoi4_agent_tools/map/service.js +93 -16
  29. package/dist/hoi4_agent_tools/map/state-restructure.js +378 -0
  30. package/dist/hoi4_agent_tools/map/territory-graph.js +238 -0
  31. package/dist/hoi4_agent_tools/map/territory-operations.js +944 -0
  32. package/dist/hoi4_agent_tools/map/territory-quality.js +189 -0
  33. package/dist/hoi4_agent_tools/map/territory.js +1108 -0
  34. package/dist/hoi4_agent_tools/map/tool-operations.js +119 -1
  35. package/dist/hoi4_agent_tools/map/validation.js +4 -0
  36. package/dist/hoi4_agent_tools/map/world-context.js +23 -0
  37. package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
  38. package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
  39. package/dist/hoi4_agent_tools/map/world.js +400 -145
  40. package/dist/hoi4_agent_tools/schemas/geography.js +12 -1
  41. package/dist/hoi4_agent_tools/schemas/map.js +277 -34
  42. package/dist/hoi4_agent_tools/version.js +1 -1
  43. package/docs/focus.md +6 -0
  44. package/docs/map.md +132 -4
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +1 -1
  47. package/schemas/configuration.schema.json +1 -1
  48. package/schemas/continuous-focus-palette.schema.json +1 -1
  49. package/schemas/custom-weighted-pool.schema.json +1 -1
  50. package/schemas/decision-inspect-input.schema.json +1 -1
  51. package/schemas/error-log-input.schema.json +1 -1
  52. package/schemas/event-feature-manifest.schema.json +1 -1
  53. package/schemas/focus-plan.schema.json +1 -1
  54. package/schemas/focus-planning-sidecar.schema.json +1 -1
  55. package/schemas/geography-model.schema.json +13 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +1541 -786
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/reference-sheets.schema.json +1 -1
  81. package/schemas/scenario-suite.schema.json +1 -1
  82. package/schemas/scenario-test-input.schema.json +1 -1
  83. package/schemas/script-validate-input.schema.json +1 -1
  84. package/schemas/shared-scenario.schema.json +1 -1
  85. package/schemas/source-lookup-input.schema.json +1 -1
  86. 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
- 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 });
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
- // 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;
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
- 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,
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
- 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,
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
- 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)
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
- const cell = y * mw + x;
487
- if (isLandCell(kind, cell) && occupant[cell] === 0)
488
- free = cell;
635
+ if (occupant[ny * mw + nx] === parentOwner)
636
+ return ny * mw + nx;
489
637
  }
490
- if (free < 0)
491
- unreachable('its source is already taken by another river');
492
- start = free;
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
- 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;
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
- 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 }),
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
- if (final === undefined)
535
- unreachable(`no coast of ${mouth.id} lies on its land`);
726
+ done.add(river.id);
536
727
  }
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
- },
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;