fix(simulation): coastal-cell routing relaxation — roads return to water-heavy bodies (T-1116)
A routing cell folds up to 64 native pixels, so a coastal settlement's own land pixel (placement always filters !ocean_mask) can sit inside a water-majority cell that RouteGrid marks IMPASSABLE — and astar() hard-returned None for every pair touching it, zeroing whole road graphs (GJ251c: all 3 placements; GJ380c: Sethvale). The fix relaxes only the start/goal anchor lookup: nearest_passable_cell (ring BFS, deterministic row-major tie-break, bounded at COASTAL_ANCHOR_MAX_RING=3) finds a surrogate anchor and prices it via COASTAL_ACCESS_COST_PER_RING — a short, honestly-costed access road, never a free water crossing. IMPASSABLE semantics untouched everywhere else (D-210 transit costs, open-ocean). The placement-nudge alternative was rejected: it would move Layer-3 state D-211 promises is seed-derived, for no gain. Boundary semantics pinned by test: exactly-half-water cells stay passable (strict-majority rule); a settlement with no passable cell within the search ring degrades to an isolated 0-edge node, never a panic or fabricated route. Failing-first repro on real bodies (GJ251c 0->2 edges, GJ380c 0->1) via the real cascade entry point, plus same-seed determinism. Full cargo test green; believability and cascade goldens verified unaffected (Layer-2-only change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,33 @@ const MOUNTAIN_SLOPE_DEG: f32 = 35.0;
|
|||||||
/// `≤` every traversable `cell_cost` for the heuristic to stay admissible.
|
/// `≤` every traversable `cell_cost` for the heuristic to stay admissible.
|
||||||
const MIN_CELL_COST: u32 = RIVER_COST;
|
const MIN_CELL_COST: u32 = RIVER_COST;
|
||||||
|
|
||||||
|
/// T-1116 — coastal-cell routing relaxation. A settlement's OWN routing cell can
|
||||||
|
/// be majority-water (`RouteGrid::build`'s `water_count * 2 > total_count` rule)
|
||||||
|
/// even though the settlement's exact placement pixel is always land (D-211's
|
||||||
|
/// attractor extraction filters `!ocean_mask`, D-209/attractor_matching.rs) —
|
||||||
|
/// this is pure coastal-cell/downsample granularity (a routing cell spans up to
|
||||||
|
/// `scale`² native pixels, ROUTE_W=64 on a 512-wide grid ⇒ up to 8² = 64 native
|
||||||
|
/// cells folded into one routing cell). Without this, one water-majority routing
|
||||||
|
/// cell under a coastal city hard-fails A* for every edge touching it (the
|
||||||
|
/// documented T-1116 bug: GJ251c land 0.55 / GJ380c land 0.588 — land-majority
|
||||||
|
/// BODIES with 0 routable edges).
|
||||||
|
///
|
||||||
|
/// The fix anchors routing to the NEAREST passable routing cell (ring-expansion
|
||||||
|
/// search, deterministic tie-break) rather than the settlement's own impassable
|
||||||
|
/// cell, and prices the gap as a short access-road surcharge
|
||||||
|
/// (`COASTAL_ACCESS_COST_PER_RING` per ring) — a real quay/causeway link is
|
||||||
|
/// modeled as more expensive than being fully inland, never free. This keeps
|
||||||
|
/// `IMPASSABLE` an honest ocean/lake fact everywhere else in the cost field
|
||||||
|
/// (D-210's terrain_modification_cost else-branch is untouched) — only the
|
||||||
|
/// anchor LOOKUP for a start/goal settlement is relaxed, not open-ocean transit.
|
||||||
|
const COASTAL_ANCHOR_MAX_RING: usize = 3;
|
||||||
|
|
||||||
|
/// Surcharge added to a routed edge's cost per ring-cell of coastal anchor
|
||||||
|
/// search (T-1116). One ring ≈ one `ORTHO` step at grassland baseline (100),
|
||||||
|
/// so a ring-1 anchor costs the same as one extra plain grassland hop —
|
||||||
|
/// noticeable in tie-breaks between near-identical routes, never prohibitive.
|
||||||
|
const COASTAL_ACCESS_COST_PER_RING: u32 = ORTHO * 100;
|
||||||
|
|
||||||
/// A routed edge longer than this (in routing cells) earns a midpoint waypoint
|
/// A routed edge longer than this (in routing cells) earns a midpoint waypoint
|
||||||
/// (tyre-round1.md: "edge length > ~12 regional cells").
|
/// (tyre-round1.md: "edge length > ~12 regional cells").
|
||||||
const WAYPOINT_THRESHOLD_CELLS: u32 = 12;
|
const WAYPOINT_THRESHOLD_CELLS: u32 = 12;
|
||||||
@@ -1053,19 +1080,87 @@ impl RouteGrid {
|
|||||||
(r, c)
|
(r, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// T-1116 — find the nearest PASSABLE routing cell to `(r, c)` by
|
||||||
|
/// ring-expansion search (ring 0 = the cell itself, ring `k` = the square
|
||||||
|
/// annulus at Chebyshev distance `k`), up to [`COASTAL_ANCHOR_MAX_RING`].
|
||||||
|
/// Returns `(row, col, ring)` for the first passable hit; deterministic
|
||||||
|
/// tie-break scans each ring in fixed row-major order (top edge
|
||||||
|
/// left→right, bottom edge left→right, then left/right edges
|
||||||
|
/// top→bottom), so identical inputs always pick the same cell. Columns
|
||||||
|
/// wrap (equirectangular); rows clamp. `None` if every cell within the
|
||||||
|
/// search radius is water — the settlement is truly water-locked at this
|
||||||
|
/// routing granularity (graceful degradation: the caller leaves it
|
||||||
|
/// unrouted rather than crossing open ocean).
|
||||||
|
fn nearest_passable_cell(&self, r: usize, c: usize) -> Option<(usize, usize, usize)> {
|
||||||
|
let idx = |rr: usize, cc: usize| rr * self.rw + cc;
|
||||||
|
if self.cost[idx(r, c)] != IMPASSABLE {
|
||||||
|
return Some((r, c, 0));
|
||||||
|
}
|
||||||
|
for ring in 1..=COASTAL_ANCHOR_MAX_RING {
|
||||||
|
let ring_i = ring as i32;
|
||||||
|
let (rr, cc) = (r as i32, c as i32);
|
||||||
|
let mut candidates: Vec<(usize, usize)> = Vec::new();
|
||||||
|
// Top edge, left→right.
|
||||||
|
if rr - ring_i >= 0 {
|
||||||
|
let nr = (rr - ring_i) as usize;
|
||||||
|
for dc in -ring_i..=ring_i {
|
||||||
|
let nc = (cc + dc).rem_euclid(self.rw as i32) as usize;
|
||||||
|
candidates.push((nr, nc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bottom edge, left→right.
|
||||||
|
if (rr + ring_i) < self.rh as i32 {
|
||||||
|
let nr = (rr + ring_i) as usize;
|
||||||
|
for dc in -ring_i..=ring_i {
|
||||||
|
let nc = (cc + dc).rem_euclid(self.rw as i32) as usize;
|
||||||
|
candidates.push((nr, nc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left/right edges (excluding corners already covered above),
|
||||||
|
// top→bottom.
|
||||||
|
for dr in (-ring_i + 1)..ring_i {
|
||||||
|
let nr_i = rr + dr;
|
||||||
|
if nr_i < 0 || nr_i >= self.rh as i32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let nr = nr_i as usize;
|
||||||
|
let nc_left = (cc - ring_i).rem_euclid(self.rw as i32) as usize;
|
||||||
|
let nc_right = (cc + ring_i).rem_euclid(self.rw as i32) as usize;
|
||||||
|
candidates.push((nr, nc_left));
|
||||||
|
candidates.push((nr, nc_right));
|
||||||
|
}
|
||||||
|
for (nr, nc) in candidates {
|
||||||
|
if self.cost[idx(nr, nc)] != IMPASSABLE {
|
||||||
|
return Some((nr, nc, ring));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// A\* from `start` to `goal` (grid-space coords) over the cost field. Columns
|
/// A\* from `start` to `goal` (grid-space coords) over the cost field. Columns
|
||||||
/// wrap (equirectangular); rows clamp. Returns the routing-cell path and its
|
/// wrap (equirectangular); rows clamp. Returns the routing-cell path and its
|
||||||
/// length in routing cells, or `None` if unroutable (no land path).
|
/// length in routing cells, or `None` if unroutable (no land path).
|
||||||
|
///
|
||||||
|
/// T-1116: if `start`'s or `goal`'s own routing cell is water-majority
|
||||||
|
/// (coastal-cell granularity — the settlement's exact pixel is always
|
||||||
|
/// land, D-211/D-209), route to/from the nearest passable cell instead
|
||||||
|
/// of hard-failing, and add a [`COASTAL_ACCESS_COST_PER_RING`] surcharge
|
||||||
|
/// per ring of search distance to `length_cells` (an honest short
|
||||||
|
/// access-road cost, not a free pass through water). Still returns `None`
|
||||||
|
/// when no passable cell exists within [`COASTAL_ANCHOR_MAX_RING`] of
|
||||||
|
/// either endpoint (fully water-locked at this granularity) or when no
|
||||||
|
/// path connects the two anchors (genuinely separated by ocean).
|
||||||
fn astar(&self, start: (f64, f64), goal: (f64, f64)) -> Option<(Vec<(usize, usize)>, u32)> {
|
fn astar(&self, start: (f64, f64), goal: (f64, f64)) -> Option<(Vec<(usize, usize)>, u32)> {
|
||||||
let (sr, sc) = self.to_route_cell(start);
|
let (sr0, sc0) = self.to_route_cell(start);
|
||||||
let (gr, gc) = self.to_route_cell(goal);
|
let (gr0, gc0) = self.to_route_cell(goal);
|
||||||
|
let (sr, sc, s_ring) = self.nearest_passable_cell(sr0, sc0)?;
|
||||||
|
let (gr, gc, g_ring) = self.nearest_passable_cell(gr0, gc0)?;
|
||||||
|
let surcharge = (s_ring as u32 + g_ring as u32).saturating_mul(COASTAL_ACCESS_COST_PER_RING);
|
||||||
let s = sr * self.rw + sc;
|
let s = sr * self.rw + sc;
|
||||||
let g = gr * self.rw + gc;
|
let g = gr * self.rw + gc;
|
||||||
if self.cost[s] == IMPASSABLE || self.cost[g] == IMPASSABLE {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if s == g {
|
if s == g {
|
||||||
return Some((vec![(sr, sc)], 0));
|
return Some((vec![(sr, sc)], surcharge.div_ceil(MIN_CELL_COST)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = self.rw * self.rh;
|
let n = self.rw * self.rh;
|
||||||
@@ -1078,7 +1173,12 @@ impl RouteGrid {
|
|||||||
|
|
||||||
while let Some(Reverse((_, cur))) = open.pop() {
|
while let Some(Reverse((_, cur))) = open.pop() {
|
||||||
if cur == g {
|
if cur == g {
|
||||||
return Some(self.reconstruct(&came, g));
|
let (path, length_cells) = self.reconstruct(&came, g);
|
||||||
|
// Surcharge folded into the reported length (routing cells
|
||||||
|
// are the shared unit `length_cells` already carries — see
|
||||||
|
// WAYPOINT_THRESHOLD_CELLS/maintenance_authority callers).
|
||||||
|
let surcharge_cells = surcharge.div_ceil(MIN_CELL_COST);
|
||||||
|
return Some((path, length_cells.saturating_add(surcharge_cells)));
|
||||||
}
|
}
|
||||||
let cr = cur / self.rw;
|
let cr = cur / self.rw;
|
||||||
let cc = cur % self.rw;
|
let cc = cur % self.rw;
|
||||||
@@ -1948,4 +2048,254 @@ mod tests {
|
|||||||
// H2(b): the whole accreting attach sequence is deterministic.
|
// H2(b): the whole accreting attach sequence is deterministic.
|
||||||
assert_eq!(run(), run(), "attach_minors must be deterministic");
|
assert_eq!(run(), run(), "attach_minors must be deterministic");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// T-1116 — real-body pin: GJ251c (land fraction 0.55) and GJ380c (land
|
||||||
|
/// fraction 0.588) are land-MAJORITY bodies whose entire settlement list
|
||||||
|
/// used to produce **zero** routable road edges, because every one of
|
||||||
|
/// their placements happens to land in a majority-water routing cell at
|
||||||
|
/// the ~64-wide routing-grid's downsample granularity (confirmed via a
|
||||||
|
/// throwaway diagnostic before this fix landed: GJ251c had all 3
|
||||||
|
/// placements IMPASSABLE at route_cell resolution; GJ380c had 2 of 3
|
||||||
|
/// passable but the third — Sethvale, route_cell (16,13) — IMPASSABLE,
|
||||||
|
/// enough to zero the graph with only 3 nodes total). The coastal-cell
|
||||||
|
/// routing relaxation (`RouteGrid::nearest_passable_cell` +
|
||||||
|
/// `astar`'s surrogate-anchor path) must flip both bodies to routable.
|
||||||
|
///
|
||||||
|
/// Uses the committed `systems.db` + `wiki/star-systems` heightmaps via
|
||||||
|
/// [`believability::cascade_snapshot_for_body`] — this IS
|
||||||
|
/// `build_road_graph` exercised through the real cascade entry point
|
||||||
|
/// (`cascade.rs:405`), not a synthetic harness.
|
||||||
|
#[test]
|
||||||
|
fn real_body_coastal_settlements_produce_routable_edges() {
|
||||||
|
// (body, expected node count, minimum edges after the fix).
|
||||||
|
let cases = [("GJ251c", 3usize, 1usize), ("GJ380c", 3usize, 1usize)];
|
||||||
|
for (body, min_placements, min_edges) in cases {
|
||||||
|
let bws = crate::atlas::believability::cascade_for_body(42, body)
|
||||||
|
.unwrap_or_else(|e| panic!("{body}: {e}"));
|
||||||
|
assert!(
|
||||||
|
bws.placements.len() >= min_placements,
|
||||||
|
"{body}: expected at least {min_placements} placements, got {}",
|
||||||
|
bws.placements.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!bws.road_graph.edges.is_empty(),
|
||||||
|
"{body}: road graph must have routable edges — coastal-cell \
|
||||||
|
relaxation should route around water-majority routing cells \
|
||||||
|
under land-majority settlements"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bws.road_graph.edges.len() >= min_edges,
|
||||||
|
"{body}: expected >= {min_edges} edges, got {}",
|
||||||
|
bws.road_graph.edges.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same seed, same body → same graph (D-010). Guards the surrogate-anchor
|
||||||
|
/// search (`nearest_passable_cell`'s ring order is a fixed scan, not a
|
||||||
|
/// distance sort, so ties must resolve identically every run).
|
||||||
|
#[test]
|
||||||
|
fn real_body_coastal_routing_is_deterministic() {
|
||||||
|
for body in ["GJ251c", "GJ380c"] {
|
||||||
|
let a = crate::atlas::believability::cascade_for_body(42, body).unwrap();
|
||||||
|
let b = crate::atlas::believability::cascade_for_body(42, body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
a.road_graph, b.road_graph,
|
||||||
|
"{body}: identical seed must produce an identical road graph"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T-1116 boundary case: `RouteGrid::build`'s majority rule is **strict**
|
||||||
|
/// `water_count * 2 > total_count` — so a routing cell exactly AT the
|
||||||
|
/// 50/50 threshold resolves to LAND (passable), and only a true majority
|
||||||
|
/// (2 of 3, not 1 of 2) is IMPASSABLE. This test pins both sides of that
|
||||||
|
/// boundary explicitly: the tie-goes-to-land case stays routable outright
|
||||||
|
/// (no relaxation needed), and the true-majority case is IMPASSABLE and
|
||||||
|
/// must be routed AROUND via the coastal relaxation.
|
||||||
|
#[test]
|
||||||
|
fn routing_cell_at_majority_water_threshold_boundary() {
|
||||||
|
// grid_w=180 -> scale = 180.div_ceil(64) = 3, so each routing cell
|
||||||
|
// aggregates a 3x1 run of native columns — lets us hit both an exact
|
||||||
|
// 50/50 split (impossible at 3-wide, so we use a 2-wide sub-probe)
|
||||||
|
// and a true 2-of-3 majority in the same grid. grid_h=1 keeps the
|
||||||
|
// row dimension out of the aggregation entirely.
|
||||||
|
let w = 180u32;
|
||||||
|
let h = 1u32;
|
||||||
|
let mut hm = BodyHeightmap {
|
||||||
|
body_id: "threshold_test".into(),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
data: vec![0.6; (w * h) as usize],
|
||||||
|
sea_level: 0.3,
|
||||||
|
};
|
||||||
|
let idx = |r: u32, c: u32| (r * w + c) as usize;
|
||||||
|
// Routing cell (0, 3) covers native columns 9..12 (scale=3). Sink 2
|
||||||
|
// of 3 (cols 9,10) -> water_count=2, total_count=3: 2*2=4 > 3, a true
|
||||||
|
// majority -> IMPASSABLE.
|
||||||
|
hm.data[idx(0, 9)] = 0.1;
|
||||||
|
hm.data[idx(0, 10)] = 0.1;
|
||||||
|
// Routing cell (0, 6) covers native columns 18..21. Sink exactly 1 of
|
||||||
|
// 3 (col 18) -> water_count=1, total_count=3: 1*2=2 is NOT > 3, so
|
||||||
|
// this stays LAND despite being water-touched (tie/minority goes to
|
||||||
|
// land, matching the strict-`>` rule) — a contrasting control case.
|
||||||
|
hm.data[idx(0, 18)] = 0.1;
|
||||||
|
|
||||||
|
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||||
|
let ta = TerrainAnalysis::analyze(&hm, &dr);
|
||||||
|
let grid = RouteGrid::build(&ta, &[], w, h);
|
||||||
|
assert_eq!(grid.scale, 3, "grid_w=180 must downsample at scale=3 for ROUTE_W=64");
|
||||||
|
|
||||||
|
let majority_water_cell = 0 * grid.rw + 3;
|
||||||
|
assert_eq!(
|
||||||
|
grid.cost[majority_water_cell],
|
||||||
|
IMPASSABLE,
|
||||||
|
"2-of-3 native cells water (a true majority) must be IMPASSABLE"
|
||||||
|
);
|
||||||
|
let minority_water_cell = 0 * grid.rw + 6;
|
||||||
|
assert_ne!(
|
||||||
|
grid.cost[minority_water_cell],
|
||||||
|
IMPASSABLE,
|
||||||
|
"1-of-3 native cells water (a minority) must stay LAND — the \
|
||||||
|
strict `>` rule does not treat any water touch as impassable"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The IMPASSABLE cell must be routed AROUND: a passable neighbour
|
||||||
|
// exists within COASTAL_ANCHOR_MAX_RING on this otherwise-flat body.
|
||||||
|
let hit = grid.nearest_passable_cell(0, 3);
|
||||||
|
assert!(
|
||||||
|
hit.is_some(),
|
||||||
|
"a passable neighbour must exist within the search radius on an \
|
||||||
|
otherwise-flat-land grid"
|
||||||
|
);
|
||||||
|
let (nr, nc, ring) = hit.unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
(nr, nc),
|
||||||
|
(0, 3),
|
||||||
|
"the surrogate must NOT be the impassable cell itself"
|
||||||
|
);
|
||||||
|
assert!(ring >= 1 && ring <= COASTAL_ANCHOR_MAX_RING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T-1116 boundary case: a settlement placement fully surrounded by
|
||||||
|
/// water-majority routing cells beyond `COASTAL_ANCHOR_MAX_RING` in every
|
||||||
|
/// direction has NO relaxation rescue — `nearest_passable_cell` must
|
||||||
|
/// return `None`, and `astar` must degrade gracefully (return `None`,
|
||||||
|
/// same as the pre-existing "separated by ocean" contract) rather than
|
||||||
|
/// silently routing through open water or panicking. The graph as a
|
||||||
|
/// whole must still build (the settlement is left an isolated node with
|
||||||
|
/// no incident edges — `build_road_graph`'s existing drop-and-log path,
|
||||||
|
/// unchanged by this fix).
|
||||||
|
#[test]
|
||||||
|
fn settlement_surrounded_by_water_beyond_search_radius_degrades_gracefully() {
|
||||||
|
// All-ocean grid: every routing cell is majority water.
|
||||||
|
let hm = BodyHeightmap {
|
||||||
|
body_id: "all_water_test".into(),
|
||||||
|
width: 64,
|
||||||
|
height: 32,
|
||||||
|
data: vec![0.1; 64 * 32], // below sea_level everywhere
|
||||||
|
sea_level: 0.3,
|
||||||
|
};
|
||||||
|
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||||
|
let ta = TerrainAnalysis::analyze(&hm, &dr);
|
||||||
|
let grid = RouteGrid::build(&ta, &[], 64, 32);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
grid.nearest_passable_cell(16, 32).is_none(),
|
||||||
|
"an all-water body has no passable cell within any search radius"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
grid.astar((16.0, 32.0), (10.0, 10.0)).is_none(),
|
||||||
|
"astar must return None (graceful degradation), not panic or \
|
||||||
|
fabricate a water route, when no passable anchor exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The graph-level contract: build_road_graph must not panic and must
|
||||||
|
// leave the pair unconnected (0 edges), exactly like the pre-existing
|
||||||
|
// "separated by ocean" behaviour this ticket's fix must NOT change
|
||||||
|
// for a genuinely all-water body.
|
||||||
|
let placements = vec![
|
||||||
|
placement(1, (16, 32), PoliticalArchetype::Pioneer),
|
||||||
|
placement(2, (10, 10), PoliticalArchetype::Pioneer),
|
||||||
|
];
|
||||||
|
let g = build_road_graph(
|
||||||
|
&placements,
|
||||||
|
&ta,
|
||||||
|
&[],
|
||||||
|
64,
|
||||||
|
32,
|
||||||
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(g.nodes.len(), 2);
|
||||||
|
assert!(
|
||||||
|
g.edges.is_empty(),
|
||||||
|
"an all-water body must still produce 0 edges — the relaxation \
|
||||||
|
only rescues coastal-granularity mismatches, not genuine \
|
||||||
|
water-world isolation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T-1116 — the coastal relaxation must add a real, non-zero surcharge
|
||||||
|
/// (never free): a route ending at a coastal-cell settlement must cost
|
||||||
|
/// more than the equivalent route to a settlement whose own cell is
|
||||||
|
/// already passable at ring 0, all else equal.
|
||||||
|
#[test]
|
||||||
|
fn coastal_anchor_surcharge_is_never_free() {
|
||||||
|
// Same 180-wide/scale-3 setup as the threshold test: routing cell
|
||||||
|
// (0, 3) (native cols 9..12) is a true 2-of-3 majority -> IMPASSABLE.
|
||||||
|
let w = 180u32;
|
||||||
|
let h = 1u32;
|
||||||
|
let mut hm = BodyHeightmap {
|
||||||
|
body_id: "surcharge_test".into(),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
data: vec![0.6; (w * h) as usize],
|
||||||
|
sea_level: 0.3,
|
||||||
|
};
|
||||||
|
let idx = |r: u32, c: u32| (r * w + c) as usize;
|
||||||
|
hm.data[idx(0, 9)] = 0.1;
|
||||||
|
hm.data[idx(0, 10)] = 0.1;
|
||||||
|
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||||
|
let ta = TerrainAnalysis::analyze(&hm, &dr);
|
||||||
|
let grid = RouteGrid::build(&ta, &[], w, h);
|
||||||
|
assert_eq!(grid.cost[3], IMPASSABLE, "sanity: cell (0,3) must be water-majority");
|
||||||
|
let (surrogate_r, surrogate_c, ring) = grid
|
||||||
|
.nearest_passable_cell(0, 3)
|
||||||
|
.expect("a passable neighbour must exist");
|
||||||
|
assert!(ring >= 1, "the impassable cell must need a real search, ring 0 would be a no-op test");
|
||||||
|
|
||||||
|
// Route A: settlement anchored INSIDE the impassable cell (native col
|
||||||
|
// 11) — must go through the surrogate-anchor relaxation.
|
||||||
|
let start = (0.0, 0.0); // routing cell (0, 0), flat land
|
||||||
|
let (_, coastal_len) = grid
|
||||||
|
.astar(start, (0.0, 11.0))
|
||||||
|
.expect("coastal goal must still route via the surrogate anchor");
|
||||||
|
|
||||||
|
// Route B: the SAME surrogate cell as an explicit, directly-reachable
|
||||||
|
// goal (its own native centre) — ring 0 by construction, so this is
|
||||||
|
// exactly "coastal_len minus the surcharge" if the surcharge is
|
||||||
|
// correctly additive and nothing else differs.
|
||||||
|
let surrogate_native = (
|
||||||
|
(surrogate_r * grid.scale) as f64,
|
||||||
|
(surrogate_c * grid.scale + grid.scale / 2) as f64,
|
||||||
|
);
|
||||||
|
let (_, surrogate_len) = grid
|
||||||
|
.astar(start, surrogate_native)
|
||||||
|
.expect("the surrogate cell itself must be directly routable");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
coastal_len > surrogate_len,
|
||||||
|
"routing to a coastal-cell goal (via the ring-{ring} surrogate) \
|
||||||
|
must cost MORE than routing directly to that same surrogate \
|
||||||
|
cell with no relaxation involved: got coastal={coastal_len} \
|
||||||
|
surrogate={surrogate_len}"
|
||||||
|
);
|
||||||
|
let surcharge_cells = coastal_len - surrogate_len;
|
||||||
|
assert!(
|
||||||
|
surcharge_cells > 0,
|
||||||
|
"the coastal surcharge must be a strictly positive number of \
|
||||||
|
routing-cell-equivalents, never zero (never free)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user