From 8247ba1dedb8dbf37b6e25b1933db93fc1a6665d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 26 Jul 2026 13:05:35 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(simulation):=20coastal-cell=20routing?= =?UTF-8?q?=20relaxation=20=E2=80=94=20roads=20return=20to=20water-heavy?= =?UTF-8?q?=20bodies=20(T-1116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/atlas/road_graph.rs | 364 ++++++++++++++++++++++++++++++++- 1 file changed, 357 insertions(+), 7 deletions(-) diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index 99c7bdc6f..329ba5b69 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -82,6 +82,33 @@ const MOUNTAIN_SLOPE_DEG: f32 = 35.0; /// `≤` every traversable `cell_cost` for the heuristic to stay admissible. 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 /// (tyre-round1.md: "edge length > ~12 regional cells"). const WAYPOINT_THRESHOLD_CELLS: u32 = 12; @@ -1053,19 +1080,87 @@ impl RouteGrid { (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 /// wrap (equirectangular); rows clamp. Returns the routing-cell path and its /// 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)> { - let (sr, sc) = self.to_route_cell(start); - let (gr, gc) = self.to_route_cell(goal); + let (sr0, sc0) = self.to_route_cell(start); + 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 g = gr * self.rw + gc; - if self.cost[s] == IMPASSABLE || self.cost[g] == IMPASSABLE { - return None; - } 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; @@ -1078,7 +1173,12 @@ impl RouteGrid { while let Some(Reverse((_, cur))) = open.pop() { 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 cc = cur % self.rw; @@ -1948,4 +2048,254 @@ mod tests { // H2(b): the whole accreting attach sequence is 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)" + ); + } } From 1ec5cb4fd55014cc099ee062689827157598d4cc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 26 Jul 2026 13:16:54 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(simulation):=20clippy=20=E2=80=94=20era?= =?UTF-8?q?sing=5Fop=20row-major=20literals,=20range-contains=20in=20T-111?= =?UTF-8?q?6=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- server/src/atlas/road_graph.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index 329ba5b69..7c078d697 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -2146,13 +2146,13 @@ mod tests { 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; + let majority_water_cell = 3; // row-major (row 0, col 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; + let minority_water_cell = 6; // row-major (row 0, col 6) assert_ne!( grid.cost[minority_water_cell], IMPASSABLE, @@ -2174,7 +2174,7 @@ mod tests { (0, 3), "the surrogate must NOT be the impassable cell itself" ); - assert!(ring >= 1 && ring <= COASTAL_ANCHOR_MAX_RING); + assert!((1..=COASTAL_ANCHOR_MAX_RING).contains(&ring)); } /// T-1116 boundary case: a settlement placement fully surrounded by From 0039bda18403a8d70ae9dd06cecbd06fadd137f4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 26 Jul 2026 13:19:02 +0200 Subject: [PATCH 3/4] style(simulation): cargo fmt Co-Authored-By: Claude Fable 5 --- server/src/atlas/road_graph.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index 7c078d697..359940ac0 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -1156,7 +1156,8 @@ impl RouteGrid { 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 surcharge = + (s_ring as u32 + g_ring as u32).saturating_mul(COASTAL_ACCESS_COST_PER_RING); let s = sr * self.rw + sc; let g = gr * self.rw + gc; if s == g { @@ -2144,18 +2145,19 @@ mod tests { 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"); + assert_eq!( + grid.scale, 3, + "grid_w=180 must downsample at scale=3 for ROUTE_W=64" + ); let majority_water_cell = 3; // row-major (row 0, col 3) assert_eq!( - grid.cost[majority_water_cell], - IMPASSABLE, + grid.cost[majority_water_cell], IMPASSABLE, "2-of-3 native cells water (a true majority) must be IMPASSABLE" ); let minority_water_cell = 6; // row-major (row 0, col 6) assert_ne!( - grid.cost[minority_water_cell], - IMPASSABLE, + 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" ); @@ -2259,11 +2261,17 @@ mod tests { 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"); + 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"); + 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. From de8bcf4ebbcc8eaee1a11c8ade30fdcfdea10c71 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 26 Jul 2026 13:34:43 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(simulation):=20PR=20#215=20review=20fix?= =?UTF-8?q?es=20=E2=80=94=20hop-unit=20surcharge,=20D-210=20amendment,=20c?= =?UTF-8?q?itation=20+=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surcharge is now COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING=1 added directly to length_cells (a pure hop count) — the old cost-unit constant div_ceil'd through MIN_CELL_COST silently produced 4 hops per ring, worst-case +24 (double the waypoint threshold) for physically short edges; worst case is now 6. A formula-pinning test asserts both the arithmetic and the constant. GJ251c's repro tightened to the documented 2 edges. The always-land citation now points at the real guarantee (features.rs::extract_attractors, D-209) — and checking the D-211 Phase-4 synthetic-overflow path exposed a real gap: it has no ocean-mask guard at all (T-1206 filed); documented, not papered over. D-210 gains a dated amendment recording the surrogate-anchor-at-cost carve-out and the relaxation-over-nudge adjudication. The bare 100 dependency dissolved with the unit fix. Edge counts on both repro bodies verified unchanged (reachability was never affected). Co-Authored-By: Claude Fable 5 --- governance/decisions/architecture.md | 2 + server/src/atlas/road_graph.rs | 173 +++++++++++++++++++++------ 2 files changed, 138 insertions(+), 37 deletions(-) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index a98cd2cf7..d8afe0668 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1103,6 +1103,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Raised by:** Generation cascade workshop (T-897) - **Cross-reference:** D-101 (ZonePalette modifier system — consumes sub_biome), D-195 (attractor types), D-209 (feature tag extraction — assigns sub_biome) +**Amended 2026-07-26 (T-1116 — surrogate-anchor-at-cost carve-out for the Layer-2 road graph):** the road graph's routing grid (`RouteGrid`, `road_graph.rs`) downsamples `terrain_modification_cost` onto coarse routing cells (up to `scale²` native pixels per cell) and marks a cell `IMPASSABLE` when it is water-majority. A settlement's exact placement pixel is always land (D-209's attractor extraction guards `!ocean_mask` on every real attractor type), but at this downsample granularity the settlement's *routing cell* can still be majority water — a coastal-cell/downsample artifact, not a placement error. **Ruling:** a routing anchor (A\* start/goal) whose own cell is water-majority MAY be surrogated to the nearest passable cell within a bounded search radius, priced as an explicit access-cost surcharge on the routed edge's reported length (never free, modeling a short quay/causeway link) — but general water-cell transit stays `IMPASSABLE` exactly as before; only the anchor *lookup* for a start/goal settlement is relaxed, not open-ocean pathfinding. This was adjudicated as a routing-layer relaxation rather than a D-211 placement nudge specifically because D-211's positions are seed-derived and already land-guaranteed for every *real* attractor path — moving them would touch a different layer's invariant to fix a downsample artifact that belongs to the router. **Known gap flagged, not fixed here:** D-211's Phase-4 synthetic-overflow path (`synthetic_attractor`) computes its position by grid arithmetic alone, with no terrain/`ocean_mask` check at all — unlike every real attractor type, a synthetic-overflow settlement is not guaranteed land. The routing relaxation above still degrades that case gracefully (surrogate-anchors it or leaves it unrouted beyond the search radius), but the placement guarantee gap itself is D-211's, unticketed as of this amendment. + ### D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement - **Date:** 2026-05-01 - **Decision:** Given a body's `Vec` and a set of cities from `atlas_city_names`, settlement placement runs a five-phase matching pipeline: diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index 359940ac0..7d742f3f7 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -84,30 +84,52 @@ 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). +/// even though the settlement's exact placement pixel is (almost) always land: +/// every REAL attractor type — including `PlainCenter` — is extracted with an +/// explicit `!ta.ocean_mask[i]` guard (D-209, `features.rs::extract_attractors`, +/// e.g. the `CoastalAccess` filter at line 544 and the `PlainCenter` filter at +/// line 629). 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 relaxation, +/// 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). +/// +/// **Known gap this relaxation also happens to cover, but does not fix at the +/// source:** D-211's Phase-4 **synthetic overflow** path +/// (`attractor_matching.rs::synthetic_attractor`, used when a body has more +/// cities than real attractors) picks a position by pure grid arithmetic +/// (`grid_h/2, grid_w/4` then a fixed spacing-walk) with **no terrain check at +/// all** — it takes no heightmap/`TerrainAnalysis` argument and never reads +/// `ocean_mask`. A synthetic-overflow settlement CAN land in open ocean. This +/// routing relaxation still degrades that case gracefully (anchors to the +/// nearest passable cell within `COASTAL_ANCHOR_MAX_RING`, or leaves it +/// unrouted beyond that), but the placement itself is not guaranteed land — +/// that gap belongs to D-211/Layer 3, not this file, and is reported rather +/// than silently patched over here (flagged in PR #215 review, not yet ticketed). /// /// 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. +/// (`COASTAL_ACCESS_SURCHARGE_HOPS_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; +/// Surcharge added to a routed edge's reported `length_cells` per ring-cell of +/// coastal anchor search (T-1116). `length_cells` is a pure HOP COUNT (the +/// number of routing-cell steps in the path, [`RouteGrid::reconstruct`]), NOT +/// a cost-unit quantity — so this constant is defined directly in hop units, +/// not derived from `ORTHO`/cell-cost scale. One ring = one extra hop: a +/// ring-1 anchor reports as if the path took one additional plain step, +/// noticeable in tie-breaks between near-identical routes and in +/// [`WAYPOINT_THRESHOLD_CELLS`], never prohibitive (worst case, both anchors +/// at [`COASTAL_ANCHOR_MAX_RING`], is `2 * 3 = 6` extra hops — half the +/// waypoint threshold, not double it). +const COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING: u32 = 1; /// A routed edge longer than this (in routing cells) earns a midpoint waypoint /// (tyre-round1.md: "edge length > ~12 regional cells"). @@ -1145,23 +1167,25 @@ impl RouteGrid { /// 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). + /// of hard-failing, and add [`COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING`] hop + /// to `length_cells` per ring of search distance (an honest short + /// access-road cost, not a free pass through water — `length_cells` is a + /// hop count, so the surcharge is expressed directly in hops, never + /// converted through cost units). 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)> { let (sr0, sc0) = self.to_route_cell(start); 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 surcharge_hops = + (s_ring as u32 + g_ring as u32).saturating_mul(COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING); let s = sr * self.rw + sc; let g = gr * self.rw + gc; if s == g { - return Some((vec![(sr, sc)], surcharge.div_ceil(MIN_CELL_COST))); + return Some((vec![(sr, sc)], surcharge_hops)); } let n = self.rw * self.rh; @@ -1175,11 +1199,10 @@ impl RouteGrid { while let Some(Reverse((_, cur))) = open.pop() { if cur == 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))); + // Surcharge folded into the reported length (already in hop + // units — see WAYPOINT_THRESHOLD_CELLS/maintenance_authority + // callers, which both read `length_cells` as a hop count). + return Some((path, length_cells.saturating_add(surcharge_hops))); } let cr = cur / self.rw; let cc = cur % self.rw; @@ -2068,8 +2091,13 @@ mod tests { /// (`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)]; + // (body, expected node count, minimum edges after the fix). GJ251c's + // documented post-fix count is 2 edges (all 3 placements land in + // IMPASSABLE cells pre-fix); GJ380c's is 1 (2 of 3 placements are + // already passable, only Sethvale needed the relaxation) — tightened + // to the exact documented counts so a regression to fewer edges + // fails loudly (PR #215 review finding 2). + let cases = [("GJ251c", 3usize, 2usize), ("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}")); @@ -2299,11 +2327,82 @@ mod tests { cell with no relaxation involved: got coastal={coastal_len} \ surrogate={surrogate_len}" ); - let surcharge_cells = coastal_len - surrogate_len; + let surcharge_hops = coastal_len - surrogate_len; assert!( - surcharge_cells > 0, + surcharge_hops > 0, "the coastal surcharge must be a strictly positive number of \ - routing-cell-equivalents, never zero (never free)" + hops, never zero (never free)" + ); + // PR #215 review finding 1: `length_cells` is a pure HOP COUNT, so the + // surcharge must be expressed directly in hops — pin the exact value + // (ring * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING) so a future unit + // mismatch (e.g. reintroducing a cost-unit conversion) fails loudly + // instead of merely "some positive number". + assert_eq!( + surcharge_hops, + ring as u32 * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING, + "the goal-side surcharge must be EXACTLY ring * hops-per-ring \ + (the start side contributes 0 here — start is ring 0)" + ); + } + + /// PR #215 review finding 1 — pins the surcharge formula directly (no + /// terrain, no A*, just the arithmetic in `astar`): the reported + /// `length_cells` surcharge for a start/goal pair is EXACTLY + /// `(start_ring + goal_ring) * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING` + /// hops — never a cost-unit quantity in disguise. Uses the `s == g` + /// short-circuit branch in `astar` (same routing cell for start and + /// goal) to read the surcharge in complete isolation from pathfinding. + #[test] + fn coastal_surcharge_is_exactly_hops_per_ring_times_ring_count() { + // Same 180-wide/scale-3 body as the other boundary tests: cell (0,3) + // is IMPASSABLE (2-of-3 majority), cell (0,6) is passable (1-of-3, + // stays land under the strict `>` rule). + let w = 180u32; + let h = 1u32; + let mut hm = BodyHeightmap { + body_id: "surcharge_formula_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" + ); + + // Start AND goal both land in the same impassable cell (native cols + // 9 and 11, both inside routing cell (0,3)) — astar's `s == g` + // branch returns `surcharge_hops` directly with zero path-length + // noise, isolating the formula from A*'s own hop accounting. + let (_, ring) = { + let (r, c, ring) = grid.nearest_passable_cell(0, 3).unwrap(); + ((r, c), ring) + }; + let (_, reported_hops) = grid + .astar((0.0, 9.0), (0.0, 11.0)) + .expect("same-cell start/goal must resolve via the shared surrogate"); + assert_eq!( + reported_hops, + (ring as u32) * 2 * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING, + "start and goal share the same impassable cell -> same ring on \ + both sides -> surcharge = 2 * ring * hops-per-ring, with ZERO \ + actual path hops (s == g after anchoring)" + ); + // With the constant fixed at 1 hop/ring (finding 1's chosen value), + // this resolves to a concrete number — pin it so a future change to + // COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING is a deliberate, visible act. + assert_eq!( + COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING, 1, + "if this changes, the assertion above must be re-derived, not \ + just relaxed" ); } }