fix(simulation): synthetic-overflow placements get the ocean-mask guard (T-1206)
synthetic_attractor now takes the terrain analysis and land-corrects its pure-arithmetic position via a bounded nearest-land ring walk (T-1116's pattern: row-major tie-break, column wrap, row clamp, MAX_LAND_SEARCH_RING=128 sized empirically — real polar ocean bands push nearest land up to 125 cells). Land positions pass through UNTOUCHED — verified by direct before/after scan of all 267 real bodies: 63 land-arithmetic placements byte-identical, and every golden/determinism harness passes unchanged. The gap was real and widespread: 46 of 109 synthetic-overflow placements sat in open water at seed 42 (e.g. GJ903c at a genuine polar ocean cell); post-fix zero, with all 109 preserved (confirmed at a second seed). Degradation is defined and pinned: no land within the bound -> the synthetic attractor is skipped and Phase 5's existing not-placed warning reports it — never a panic, never a fabricated water position (the bound never triggers on any scanned real body). 9 new unit tests; road_graph's anchor comment and the D-210 amendment record the gap CLOSED (validated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1105,6 +1105,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
**Gap CLOSED 2026-07-26 (T-1206):** `synthetic_attractor` now takes an `Option<&TerrainAnalysis>` and guards its grid-arithmetic position against `ocean_mask` — land-passthrough (an already-land position is untouched; verified byte-identical on all 63 real bodies' land-arithmetic synthetic placements at world seed 42), water-only-correction (a deterministic ring-walk, the T-1116 `nearest_passable_cell` pattern at native resolution, nudges a water position to the nearest land cell — verified on all 46 real bodies found water-stranded pre-fix), and a defined skip (never a fabricated position or a panic) when no land exists within the bounded search radius. D-211's seed-derived-position promise holds: no existing land placement moved on any scanned body.
|
||||||
|
|
||||||
### D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement
|
### D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement
|
||||||
- **Date:** 2026-05-01
|
- **Date:** 2026-05-01
|
||||||
- **Decision:** Given a body's `Vec<GeographicAttractor>` and a set of cities from `atlas_city_names`, settlement placement runs a five-phase matching pipeline:
|
- **Decision:** Given a body's `Vec<GeographicAttractor>` and a set of cities from `atlas_city_names`, settlement placement runs a five-phase matching pipeline:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
use crate::atlas::features::NO_WATER_BEARING;
|
use crate::atlas::features::{TerrainAnalysis, NO_WATER_BEARING};
|
||||||
use crate::seed::{splitmix64, SeedChain};
|
use crate::seed::{splitmix64, SeedChain};
|
||||||
use crate::simulation::generator::{
|
use crate::simulation::generator::{
|
||||||
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, SubBiomeVariant,
|
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, SubBiomeVariant,
|
||||||
@@ -267,7 +267,109 @@ fn hungarian(cost: &[Vec<i64>]) -> Vec<usize> {
|
|||||||
/// Minimum pixel separation between synthetic attractor positions.
|
/// Minimum pixel separation between synthetic attractor positions.
|
||||||
const MIN_SPACING: u16 = 15;
|
const MIN_SPACING: u16 = 15;
|
||||||
|
|
||||||
fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> GeographicAttractor {
|
/// T-1206 — ocean-mask guard search bound. Bounded ring-expansion search
|
||||||
|
/// radius (cells) `nearest_land_cell` will walk from a water-arithmetic
|
||||||
|
/// synthetic position before giving up.
|
||||||
|
///
|
||||||
|
/// **Sized empirically, not by analogy.** An early draft mirrored
|
||||||
|
/// `road_graph::COASTAL_ANCHOR_MAX_RING` (3) scaled up an order of magnitude
|
||||||
|
/// (20) — but a live scan of every body with a heightmap (T-1206 verification
|
||||||
|
/// scan, world seed 42) showed the dominant failure mode is `(0,0)` (the
|
||||||
|
/// arithmetic search loop's own first candidate, see `synthetic_attractor`)
|
||||||
|
/// landing in a genuine HIGH-LATITUDE OCEAN BAND near the grid's pole row —
|
||||||
|
/// not a small coastal-cell artifact. Real, substantially-land bodies (35–95%
|
||||||
|
/// land overall) had their nearest land cell to `(0,0)` as far as 125 cells
|
||||||
|
/// away (`grid_h` = 256, so `grid_h / 2` = 128 is the natural ceiling — a
|
||||||
|
/// point beyond that is more than half the grid's height from the pole and a
|
||||||
|
/// wider search stops paying for itself). 128 comfortably covers every
|
||||||
|
/// observed real-body case while still being a bounded, cheap search (worst
|
||||||
|
/// case ~128² ≈ 16k candidate cells, negligible next to the cascade's other
|
||||||
|
/// per-body costs) — genuinely water-locked bodies (no land within half the
|
||||||
|
/// grid height of the arithmetic position) still correctly degrade to the
|
||||||
|
/// skip path below.
|
||||||
|
const MAX_LAND_SEARCH_RING: u16 = 128;
|
||||||
|
|
||||||
|
/// Ring-expansion search from `(row, col)` to the nearest cell with
|
||||||
|
/// `!ocean_mask` (T-1206, the T-1116 `nearest_passable_cell` pattern at
|
||||||
|
/// native/working-grid resolution rather than the road graph's downsampled
|
||||||
|
/// routing grid). Deterministic: rings expand outward in fixed distance
|
||||||
|
/// order, and within a ring, candidates are visited in a fixed row-major
|
||||||
|
/// scan (top edge left→right, bottom edge left→right, then left/right edges
|
||||||
|
/// top→bottom) — the same tie-break order as `nearest_passable_cell` — so
|
||||||
|
/// two equidistant land cells always resolve to the same winner for a given
|
||||||
|
/// terrain (pure function of `(row, col, terrain)`, no RNG, D-211's
|
||||||
|
/// seed-derived-position promise intact). Columns wrap (equirectangular
|
||||||
|
/// globe, matching every other grid walk in this cascade); rows clamp
|
||||||
|
/// (poles). Returns `None` if no land cell exists within
|
||||||
|
/// [`MAX_LAND_SEARCH_RING`] — the caller's defined degradation is to skip
|
||||||
|
/// the synthetic attractor entirely (T-1206), never to fabricate a position.
|
||||||
|
fn nearest_land_cell(ta: &TerrainAnalysis, row: u16, col: u16) -> Option<(u16, u16)> {
|
||||||
|
let (w, h) = (ta.w as i32, ta.h as i32);
|
||||||
|
if !ta.is_ocean(row as usize, col as usize) {
|
||||||
|
return Some((row, col));
|
||||||
|
}
|
||||||
|
for ring in 1..=MAX_LAND_SEARCH_RING {
|
||||||
|
let ring_i = ring as i32;
|
||||||
|
let (r0, c0) = (row as i32, col as i32);
|
||||||
|
let mut candidates: Vec<(i32, i32)> = Vec::new();
|
||||||
|
// Top edge, left→right.
|
||||||
|
if r0 - ring_i >= 0 {
|
||||||
|
let nr = r0 - ring_i;
|
||||||
|
for dc in -ring_i..=ring_i {
|
||||||
|
candidates.push((nr, c0 + dc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bottom edge, left→right.
|
||||||
|
if r0 + ring_i < h {
|
||||||
|
let nr = r0 + ring_i;
|
||||||
|
for dc in -ring_i..=ring_i {
|
||||||
|
candidates.push((nr, c0 + dc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left/right edges (corners already covered above), top→bottom.
|
||||||
|
for dr in (-ring_i + 1)..ring_i {
|
||||||
|
let nr = r0 + dr;
|
||||||
|
if nr < 0 || nr >= h {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push((nr, c0 - ring_i));
|
||||||
|
candidates.push((nr, c0 + ring_i));
|
||||||
|
}
|
||||||
|
for (nr, nc) in candidates {
|
||||||
|
let wrapped_c = nc.rem_euclid(w) as u16;
|
||||||
|
if !ta.is_ocean(nr as usize, wrapped_c as usize) {
|
||||||
|
return Some((nr as u16, wrapped_c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the D-211 Phase-4 synthetic-overflow position by pure grid
|
||||||
|
/// arithmetic (spacing-walk from grid center), then guards it against open
|
||||||
|
/// water (T-1206). `terrain` is `None` for callers without terrain data
|
||||||
|
/// (existing unit tests, any future caller that hasn't threaded it through) —
|
||||||
|
/// the guard is then a no-op and the arithmetic position passes straight
|
||||||
|
/// through UNCHANGED, exactly as before this ticket (byte-identical to the
|
||||||
|
/// pre-T-1206 behavior).
|
||||||
|
///
|
||||||
|
/// **Land-passthrough, water-only-correction (T-1206):** when `terrain` IS
|
||||||
|
/// supplied, a synthetic position that is already land is returned
|
||||||
|
/// UNTOUCHED — this is the compatibility invariant the fix is built around:
|
||||||
|
/// no currently-land synthetic-overflow placement moves on any existing body
|
||||||
|
/// (D-211's seed-derived-position promise). Only a water-arithmetic position
|
||||||
|
/// is nudged, via [`nearest_land_cell`]'s deterministic ring-walk. If no land
|
||||||
|
/// cell exists within [`MAX_LAND_SEARCH_RING`] (an all-ocean region of the
|
||||||
|
/// grid), returns `None` — the caller's defined degradation is to skip this
|
||||||
|
/// synthetic attractor entirely (the city goes unplaced and is reported by
|
||||||
|
/// Phase 5's existing name-fulfillment warning) rather than fabricate a
|
||||||
|
/// water position or panic.
|
||||||
|
fn synthetic_attractor(
|
||||||
|
placed: &[CityPlacement],
|
||||||
|
grid_w: u32,
|
||||||
|
grid_h: u32,
|
||||||
|
terrain: Option<&TerrainAnalysis>,
|
||||||
|
) -> Option<GeographicAttractor> {
|
||||||
// Place at grid center as default, then walk until spacing is satisfied.
|
// Place at grid center as default, then walk until spacing is satisfied.
|
||||||
let mut row = (grid_h / 2) as u16;
|
let mut row = (grid_h / 2) as u16;
|
||||||
let mut col = (grid_w / 4) as u16;
|
let mut col = (grid_w / 4) as u16;
|
||||||
@@ -290,14 +392,20 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GeographicAttractor {
|
if let Some(ta) = terrain {
|
||||||
|
let (land_row, land_col) = nearest_land_cell(ta, row, col)?;
|
||||||
|
row = land_row;
|
||||||
|
col = land_col;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(GeographicAttractor {
|
||||||
position: (row, col),
|
position: (row, col),
|
||||||
attractor_type: AttractorType::PlainCenter,
|
attractor_type: AttractorType::PlainCenter,
|
||||||
strength: 50,
|
strength: 50,
|
||||||
sub_biome: SubBiomeVariant::TemperateGrassland,
|
sub_biome: SubBiomeVariant::TemperateGrassland,
|
||||||
terrain_modification_cost: 100,
|
terrain_modification_cost: 100,
|
||||||
water_bearing: NO_WATER_BEARING, // inland synthetic — no water direction
|
water_bearing: NO_WATER_BEARING, // inland synthetic — no water direction
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -335,6 +443,12 @@ fn city_character(
|
|||||||
(archetype, pattern, orientation)
|
(archetype, pattern, orientation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `terrain` is the body's [`TerrainAnalysis`] (T-1206), consulted ONLY by the
|
||||||
|
/// Phase-4 synthetic-overflow path to keep a synthetic `PlainCenter` off open
|
||||||
|
/// water — see [`synthetic_attractor`]'s doc for the land-passthrough /
|
||||||
|
/// water-only-correction / bounded-search-then-skip contract. `None` (every
|
||||||
|
/// pre-T-1206 caller, and any test that doesn't need the guard) reproduces
|
||||||
|
/// the exact prior behavior: an ungated grid-arithmetic position.
|
||||||
pub fn match_cities(
|
pub fn match_cities(
|
||||||
cities: &[CityRecord],
|
cities: &[CityRecord],
|
||||||
attractors: &[GeographicAttractor],
|
attractors: &[GeographicAttractor],
|
||||||
@@ -344,6 +458,7 @@ pub fn match_cities(
|
|||||||
grid_h: u32,
|
grid_h: u32,
|
||||||
territorial_status: &TerritorialStatus,
|
territorial_status: &TerritorialStatus,
|
||||||
seed: SeedChain,
|
seed: SeedChain,
|
||||||
|
terrain: Option<&TerrainAnalysis>,
|
||||||
) -> Vec<CityPlacement> {
|
) -> Vec<CityPlacement> {
|
||||||
let default_cost = vec![100i32; attractors.len()];
|
let default_cost = vec![100i32; attractors.len()];
|
||||||
let costs = terrain_costs.unwrap_or(&default_cost);
|
let costs = terrain_costs.unwrap_or(&default_cost);
|
||||||
@@ -497,7 +612,14 @@ pub fn match_cities(
|
|||||||
if placed_ids.contains(&city.city_id) {
|
if placed_ids.contains(&city.city_id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let synthetic = synthetic_attractor(&placements, grid_w, grid_h);
|
// T-1206: `None` here (all-ocean region beyond MAX_LAND_SEARCH_RING,
|
||||||
|
// or degenerate MIN_SPACING exhaustion) means the defined degradation
|
||||||
|
// is to skip this city's synthetic placement entirely — never
|
||||||
|
// fabricate a water position. Phase 5's name-fulfillment check below
|
||||||
|
// reports it (the same warning path an unplaced city already takes).
|
||||||
|
let Some(synthetic) = synthetic_attractor(&placements, grid_w, grid_h, terrain) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let score = cell_score(city, &synthetic, matrix, 100);
|
let score = cell_score(city, &synthetic, matrix, 100);
|
||||||
flag_mismatch(&city.name, score);
|
flag_mismatch(&city.name, score);
|
||||||
let (archetype, pattern, orientation) = city_character(
|
let (archetype, pattern, orientation) = city_character(
|
||||||
@@ -743,6 +865,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(placements.len(), 1);
|
assert_eq!(placements.len(), 1);
|
||||||
assert_eq!(placements[0].city_id, 1);
|
assert_eq!(placements[0].city_id, 1);
|
||||||
@@ -777,6 +900,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(placements.len(), 3);
|
assert_eq!(placements.len(), 3);
|
||||||
|
|
||||||
@@ -818,6 +942,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
|
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||||
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
|
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
|
||||||
@@ -841,6 +966,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(placements.len(), 2);
|
assert_eq!(placements.len(), 2);
|
||||||
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
|
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
|
||||||
@@ -877,6 +1003,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let major = placements.iter().find(|p| p.city_id == 1).unwrap();
|
let major = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||||
let minor = placements.iter().find(|p| p.city_id == 2).unwrap();
|
let minor = placements.iter().find(|p| p.city_id == 2).unwrap();
|
||||||
@@ -908,6 +1035,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let hero = placements2.iter().find(|p| p.city_id == 3).unwrap();
|
let hero = placements2.iter().find(|p| p.city_id == 3).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -936,6 +1064,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(placements.len(), 5, "all cities must be placed");
|
assert_eq!(placements.len(), 5, "all cities must be placed");
|
||||||
}
|
}
|
||||||
@@ -981,6 +1110,7 @@ mod tests {
|
|||||||
256,
|
256,
|
||||||
&TerritorialStatus::FrontierUnclaimed,
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
SeedChain::root(42),
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(placements.len(), 2);
|
assert_eq!(placements.len(), 2);
|
||||||
let farm = placements.iter().find(|p| p.city_id == 1).unwrap();
|
let farm = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||||
@@ -1119,4 +1249,308 @@ mod tests {
|
|||||||
ArrangementPattern::HubAndSpoke
|
ArrangementPattern::HubAndSpoke
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// T-1206: synthetic-overflow ocean-mask guard
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Builds a minimal `TerrainAnalysis` with only `w`/`h`/`ocean_mask`
|
||||||
|
/// meaningfully populated — the only fields `nearest_land_cell`/`is_ocean`
|
||||||
|
/// read. `land_cells` are `(row, col)` positions that are land; every
|
||||||
|
/// other cell in the `w × h` grid is ocean.
|
||||||
|
fn ta_with_land(w: usize, h: usize, land_cells: &[(u16, u16)]) -> TerrainAnalysis {
|
||||||
|
let mut ocean_mask = vec![true; w * h];
|
||||||
|
for &(r, c) in land_cells {
|
||||||
|
ocean_mask[r as usize * w + c as usize] = false;
|
||||||
|
}
|
||||||
|
TerrainAnalysis {
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
ocean_mask,
|
||||||
|
lake_mask: vec![false; w * h],
|
||||||
|
water_dist: vec![0; w * h],
|
||||||
|
slope_deg: vec![0.0; w * h],
|
||||||
|
elev_pct: vec![0.0; w * h],
|
||||||
|
hydrology: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All-land terrain (every cell passes `!ocean_mask`) — a convenience for
|
||||||
|
/// tests that only care about the spacing-walk arithmetic, not the guard.
|
||||||
|
fn ta_all_land(w: usize, h: usize) -> TerrainAnalysis {
|
||||||
|
TerrainAnalysis {
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
ocean_mask: vec![false; w * h],
|
||||||
|
lake_mask: vec![false; w * h],
|
||||||
|
water_dist: vec![0; w * h],
|
||||||
|
slope_deg: vec![0.0; w * h],
|
||||||
|
elev_pct: vec![0.0; w * h],
|
||||||
|
hydrology: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Compatibility invariant (T-1206, requirement 1):** a synthetic
|
||||||
|
/// position that is ALREADY LAND is returned byte-identical whether or
|
||||||
|
/// not terrain is supplied — the guard must never move an
|
||||||
|
/// already-land placement. This is the exact property that protects every
|
||||||
|
/// existing golden fixture: on a body where the arithmetic synthetic
|
||||||
|
/// position happens to be land (the common case, per the T-1206
|
||||||
|
/// verification scan), passing `Some(&terrain)` changes nothing.
|
||||||
|
#[test]
|
||||||
|
fn synthetic_attractor_land_passthrough_is_byte_identical_to_no_terrain() {
|
||||||
|
let placed: Vec<CityPlacement> = Vec::new();
|
||||||
|
let (grid_w, grid_h) = (512u32, 256u32);
|
||||||
|
// The arithmetic default before any spacing-walk iteration is
|
||||||
|
// (grid_h/2, grid_w/4) = (128, 128); make that cell (and enough of a
|
||||||
|
// margin around it) land so the very first candidate the spacing-walk
|
||||||
|
// tries — (0,0) — is NOT what gets returned, isolating the
|
||||||
|
// passthrough check to the "arithmetic position already lands on
|
||||||
|
// land" case rather than accidentally exercising the guard.
|
||||||
|
let ta = ta_all_land(grid_w as usize, grid_h as usize);
|
||||||
|
|
||||||
|
let without_terrain = synthetic_attractor(&placed, grid_w, grid_h, None)
|
||||||
|
.expect("no terrain — always Some, ungated");
|
||||||
|
let with_terrain = synthetic_attractor(&placed, grid_w, grid_h, Some(&ta))
|
||||||
|
.expect("all-land terrain — guard is a no-op, must still be Some");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
without_terrain.position, with_terrain.position,
|
||||||
|
"a land-arithmetic synthetic position must be untouched by the T-1206 guard"
|
||||||
|
);
|
||||||
|
// Sanity: this is genuinely the (0,0) first-candidate case the T-1206
|
||||||
|
// scan found in production (empty `placed` list — the spacing check
|
||||||
|
// against zero existing placements passes trivially at (0,0)).
|
||||||
|
assert_eq!(without_terrain.position, (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Water-only-correction (T-1206, requirement 1):** when the arithmetic
|
||||||
|
/// position IS water, the guard nudges it to the nearest land cell via
|
||||||
|
/// `nearest_land_cell`'s deterministic ring-walk — never to a fabricated
|
||||||
|
/// or RNG-derived position.
|
||||||
|
#[test]
|
||||||
|
fn synthetic_attractor_nudges_off_water_to_nearest_land() {
|
||||||
|
let placed: Vec<CityPlacement> = Vec::new();
|
||||||
|
let (grid_w, grid_h) = (64u32, 32u32);
|
||||||
|
// Arithmetic position (with empty `placed`) is (0,0) — make that
|
||||||
|
// water, with the nearest land cell at (2,0) (ring 2, straight south).
|
||||||
|
let ta = ta_with_land(grid_w as usize, grid_h as usize, &[(2, 0)]);
|
||||||
|
|
||||||
|
let guarded = synthetic_attractor(&placed, grid_w, grid_h, Some(&ta))
|
||||||
|
.expect("land exists within MAX_LAND_SEARCH_RING");
|
||||||
|
assert_eq!(
|
||||||
|
guarded.position,
|
||||||
|
(2, 0),
|
||||||
|
"must land exactly on the nearest land cell, not merely off water"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Row-major ring tie-break determinism (T-1206, D-010):** when two
|
||||||
|
/// land cells are equidistant (same ring) from the water-arithmetic
|
||||||
|
/// position, `nearest_land_cell` always resolves to the same one — a
|
||||||
|
/// pure function of `(position, terrain)`, matching the T-1116
|
||||||
|
/// `nearest_passable_cell` precedent's tie-break order (top edge
|
||||||
|
/// left→right first).
|
||||||
|
#[test]
|
||||||
|
fn nearest_land_cell_tie_break_is_deterministic() {
|
||||||
|
let (w, h) = (32usize, 32usize);
|
||||||
|
// Two land cells on ring 1 from (5,5): (4,4) [top-edge, visited
|
||||||
|
// first] and (4,6) [also top-edge, visited after (4,4) — left→right
|
||||||
|
// scan order]. The top-edge candidate list is built left→right, so
|
||||||
|
// (4,4) must win over (4,6) even though both are ring-1/Chebyshev-1.
|
||||||
|
let ta = ta_with_land(w, h, &[(4, 4), (4, 6)]);
|
||||||
|
let found = nearest_land_cell(&ta, 5, 5);
|
||||||
|
assert_eq!(found, Some((4, 4)));
|
||||||
|
|
||||||
|
// Repeat many times — pure function, must be exactly reproducible.
|
||||||
|
for _ in 0..20 {
|
||||||
|
assert_eq!(nearest_land_cell(&ta, 5, 5), Some((4, 4)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Column wrap (T-1206):** the working grid is equirectangular — a
|
||||||
|
/// search that walks off the left/right edge wraps around, matching
|
||||||
|
/// every other grid walk in this cascade (`features.rs::wrap_col`,
|
||||||
|
/// `road_graph.rs::nearest_passable_cell`).
|
||||||
|
#[test]
|
||||||
|
fn nearest_land_cell_wraps_columns() {
|
||||||
|
let (w, h) = (16usize, 16usize);
|
||||||
|
// Land only at column 0 — from (5, 1) (one step from the right edge
|
||||||
|
// of the wrap, i.e. effectively adjacent to column 0 via wraparound
|
||||||
|
// is NOT the case here; instead test from col=0 neighbourhood
|
||||||
|
// directly) we confirm wrap by placing land at col (w-1) and
|
||||||
|
// searching from col 0, which should find it by wrapping left.
|
||||||
|
let ta = ta_with_land(w, h, &[(5, (w - 1) as u16)]);
|
||||||
|
let found = nearest_land_cell(&ta, 5, 0);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
Some((5, (w - 1) as u16)),
|
||||||
|
"search from col 0 must wrap left to find land at the opposite edge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Rows clamp, do not wrap (T-1206):** matches every other grid walk in
|
||||||
|
/// this cascade (poles are grid edges, not a torus in the row direction).
|
||||||
|
/// A ring whose top/bottom edge would fall outside `[0, h)` simply omits
|
||||||
|
/// that edge's candidates rather than wrapping to the opposite pole.
|
||||||
|
#[test]
|
||||||
|
fn nearest_land_cell_clamps_rows_no_wrap() {
|
||||||
|
let (w, h) = (16usize, 16usize);
|
||||||
|
// Land only at the FAR pole (row h-1) — from row 0, a row-wrapping
|
||||||
|
// implementation would find it at ring (h-1); a row-clamping one
|
||||||
|
// must exhaust MAX_LAND_SEARCH_RING first if h-1 > that bound, or
|
||||||
|
// find it only via the correct non-wrapped ring distance. Here
|
||||||
|
// h=16 keeps h-1=15 comfortably inside MAX_LAND_SEARCH_RING (128),
|
||||||
|
// so the assertion is on the POSITION found, not on absence: a
|
||||||
|
// wrapping bug would still find (15, c) — same as clamping would,
|
||||||
|
// since row 15 IS within the grid — so instead assert a cell just
|
||||||
|
// below row 0 wrapping to the top is never sourced from "negative
|
||||||
|
// row mod h" by using an asymmetric single-land-cell placement at a
|
||||||
|
// row that would be reached MUCH sooner via wraparound than via the
|
||||||
|
// real clamped ring distance.
|
||||||
|
let ta = ta_with_land(w, h, &[(15, 0)]);
|
||||||
|
let found = nearest_land_cell(&ta, 0, 0);
|
||||||
|
// Real (clamped) distance is ring 15 (straight down the column).
|
||||||
|
// A wrapping implementation could equally reach it at ring 1 (one
|
||||||
|
// step "up" from row 0 wrapping to row 15) — assert the ring-15
|
||||||
|
// (non-wrapped) result to pin clamping behavior.
|
||||||
|
assert_eq!(found, Some((15, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Degradation (T-1206, requirement 2): no land within
|
||||||
|
/// `MAX_LAND_SEARCH_RING` → skip, never fabricate or panic.** A tiny
|
||||||
|
/// all-ocean grid guarantees no land cell exists anywhere, so the guard
|
||||||
|
/// must return `None` — `match_cities`'s Phase 4 then skips this city
|
||||||
|
/// entirely (verified below via `match_cities` directly, matching Phase
|
||||||
|
/// 5's existing "unplaced city" warning path — no new error path).
|
||||||
|
#[test]
|
||||||
|
fn nearest_land_cell_returns_none_when_no_land_within_bound() {
|
||||||
|
let (w, h) = (16usize, 16usize);
|
||||||
|
let ta = TerrainAnalysis {
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
ocean_mask: vec![true; w * h], // every cell is water — no land at all
|
||||||
|
lake_mask: vec![false; w * h],
|
||||||
|
water_dist: vec![0; w * h],
|
||||||
|
slope_deg: vec![0.0; w * h],
|
||||||
|
elev_pct: vec![0.0; w * h],
|
||||||
|
hydrology: None,
|
||||||
|
};
|
||||||
|
assert_eq!(nearest_land_cell(&ta, 0, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_attractor_returns_none_on_all_ocean_terrain() {
|
||||||
|
let placed: Vec<CityPlacement> = Vec::new();
|
||||||
|
let (grid_w, grid_h) = (16u32, 16u32);
|
||||||
|
let ta = TerrainAnalysis {
|
||||||
|
w: grid_w as usize,
|
||||||
|
h: grid_h as usize,
|
||||||
|
ocean_mask: vec![true; (grid_w * grid_h) as usize],
|
||||||
|
lake_mask: vec![false; (grid_w * grid_h) as usize],
|
||||||
|
water_dist: vec![0; (grid_w * grid_h) as usize],
|
||||||
|
slope_deg: vec![0.0; (grid_w * grid_h) as usize],
|
||||||
|
elev_pct: vec![0.0; (grid_w * grid_h) as usize],
|
||||||
|
hydrology: None,
|
||||||
|
};
|
||||||
|
assert!(synthetic_attractor(&placed, grid_w, grid_h, Some(&ta)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **End-to-end degradation through `match_cities` (T-1206, requirement
|
||||||
|
/// 2):** on an all-ocean grid, a city that would overflow to Phase 4
|
||||||
|
/// synthetic placement is instead SKIPPED — never placed in water, never
|
||||||
|
/// a panic. This mirrors the existing "atlas city was not placed" Phase-5
|
||||||
|
/// warning path (no new reporting mechanism needed).
|
||||||
|
#[test]
|
||||||
|
fn match_cities_skips_synthetic_overflow_on_all_ocean_body() {
|
||||||
|
let cities = vec![make_city(1, SettlementClass::PopulationBudget, 60_000)];
|
||||||
|
let attractors: Vec<GeographicAttractor> = Vec::new(); // forces Phase-4 overflow
|
||||||
|
let matrix = uniform_matrix();
|
||||||
|
let (grid_w, grid_h) = (16u32, 16u32);
|
||||||
|
let n = (grid_w * grid_h) as usize;
|
||||||
|
let ta = TerrainAnalysis {
|
||||||
|
w: grid_w as usize,
|
||||||
|
h: grid_h as usize,
|
||||||
|
ocean_mask: vec![true; n],
|
||||||
|
lake_mask: vec![false; n],
|
||||||
|
water_dist: vec![0; n],
|
||||||
|
slope_deg: vec![0.0; n],
|
||||||
|
elev_pct: vec![0.0; n],
|
||||||
|
hydrology: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let placements = match_cities(
|
||||||
|
&cities,
|
||||||
|
&attractors,
|
||||||
|
&matrix,
|
||||||
|
None,
|
||||||
|
grid_w,
|
||||||
|
grid_h,
|
||||||
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
|
SeedChain::root(42),
|
||||||
|
Some(&ta),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
placements.is_empty(),
|
||||||
|
"an all-ocean body must SKIP the overflow city, not fabricate a water placement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`match_cities` end-to-end land-guard (T-1206):** the exact
|
||||||
|
/// production shape — Phase-4 overflow on a body with real terrain —
|
||||||
|
/// never places a synthetic `CityPlacement` on an ocean cell.
|
||||||
|
#[test]
|
||||||
|
fn match_cities_synthetic_overflow_never_lands_in_water() {
|
||||||
|
let cities = vec![make_city(1, SettlementClass::PopulationBudget, 60_000)];
|
||||||
|
let attractors: Vec<GeographicAttractor> = Vec::new(); // forces Phase-4 overflow
|
||||||
|
let matrix = uniform_matrix();
|
||||||
|
let (grid_w, grid_h) = (64u32, 32u32);
|
||||||
|
// Water everywhere except a single land cell far from the arithmetic
|
||||||
|
// (0,0) default, forcing the guard to actually nudge the position.
|
||||||
|
let ta = ta_with_land(grid_w as usize, grid_h as usize, &[(10, 10)]);
|
||||||
|
|
||||||
|
let placements = match_cities(
|
||||||
|
&cities,
|
||||||
|
&attractors,
|
||||||
|
&matrix,
|
||||||
|
None,
|
||||||
|
grid_w,
|
||||||
|
grid_h,
|
||||||
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
|
SeedChain::root(42),
|
||||||
|
Some(&ta),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(placements.len(), 1);
|
||||||
|
assert_eq!(placements[0].position, (10, 10));
|
||||||
|
assert!(placements[0].synthetic);
|
||||||
|
assert!(!ta.is_ocean(
|
||||||
|
placements[0].position.0 as usize,
|
||||||
|
placements[0].position.1 as usize
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **`None` terrain reproduces the exact pre-T-1206 behavior
|
||||||
|
/// (compatibility):** every existing caller/test that doesn't supply
|
||||||
|
/// terrain gets the ungated grid-arithmetic position, unchanged.
|
||||||
|
#[test]
|
||||||
|
fn match_cities_with_no_terrain_is_ungated_like_before_t1206() {
|
||||||
|
let cities = vec![make_city(1, SettlementClass::PopulationBudget, 60_000)];
|
||||||
|
let attractors: Vec<GeographicAttractor> = Vec::new();
|
||||||
|
let matrix = uniform_matrix();
|
||||||
|
let placements = match_cities(
|
||||||
|
&cities,
|
||||||
|
&attractors,
|
||||||
|
&matrix,
|
||||||
|
None,
|
||||||
|
512,
|
||||||
|
256,
|
||||||
|
&TerritorialStatus::FrontierUnclaimed,
|
||||||
|
SeedChain::root(42),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(placements.len(), 1);
|
||||||
|
assert_eq!(placements[0].position, (0, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ impl CascadeSnapshot {
|
|||||||
///
|
///
|
||||||
/// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed`
|
/// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed`
|
||||||
/// drive the per-settlement spatial-character enrichment (#956, D-213/214/215).
|
/// drive the per-settlement spatial-character enrichment (#956, D-213/214/215).
|
||||||
|
/// `terrain` (T-1206) gates the Phase-4 synthetic-overflow path against open
|
||||||
|
/// water — see [`match_cities`]'s doc.
|
||||||
fn run_layer3(
|
fn run_layer3(
|
||||||
attractors: &[GeographicAttractor],
|
attractors: &[GeographicAttractor],
|
||||||
cities: &[CityRecord],
|
cities: &[CityRecord],
|
||||||
@@ -203,6 +205,7 @@ fn run_layer3(
|
|||||||
seed: SeedChain,
|
seed: SeedChain,
|
||||||
grid_w: u32,
|
grid_w: u32,
|
||||||
grid_h: u32,
|
grid_h: u32,
|
||||||
|
terrain: Option<&TerrainAnalysis>,
|
||||||
) -> Layer3Output {
|
) -> Layer3Output {
|
||||||
let matrix = CompatibilityMatrix::d195();
|
let matrix = CompatibilityMatrix::d195();
|
||||||
let placements = match_cities(
|
let placements = match_cities(
|
||||||
@@ -214,6 +217,7 @@ fn run_layer3(
|
|||||||
grid_h,
|
grid_h,
|
||||||
territorial_status,
|
territorial_status,
|
||||||
seed,
|
seed,
|
||||||
|
terrain,
|
||||||
);
|
);
|
||||||
Layer3Output { placements }
|
Layer3Output { placements }
|
||||||
}
|
}
|
||||||
@@ -321,8 +325,15 @@ pub fn run_cascade_from_heightmap(
|
|||||||
None => &[],
|
None => &[],
|
||||||
};
|
};
|
||||||
// cache seam: run_layer3 is a pure, deterministic function of
|
// cache seam: run_layer3 is a pure, deterministic function of
|
||||||
// (attractors, cities, territorial_status, seed) — wrap a persistent
|
// (attractors, cities, territorial_status, seed, terrain) — wrap a
|
||||||
// cache here when we add one (build-time bake or local cache; see #1021).
|
// persistent cache here when we add one (build-time bake or local
|
||||||
|
// cache; see #1021).
|
||||||
|
//
|
||||||
|
// T-1206: `snapshot.terrain_analysis` was just populated by the
|
||||||
|
// Topography block above (guaranteed `Some` here — Settlement >
|
||||||
|
// Topography in CascadeLayer's Ord, so the guard above always ran
|
||||||
|
// first) — passed by reference so the DistrictProfile/RoadGraph pass
|
||||||
|
// below still gets to consume (and drop) the same transient value.
|
||||||
let l3 = run_layer3(
|
let l3 = run_layer3(
|
||||||
attractors,
|
attractors,
|
||||||
cities,
|
cities,
|
||||||
@@ -330,6 +341,7 @@ pub fn run_cascade_from_heightmap(
|
|||||||
body_seed,
|
body_seed,
|
||||||
snapshot.heightmap.width,
|
snapshot.heightmap.width,
|
||||||
snapshot.heightmap.height,
|
snapshot.heightmap.height,
|
||||||
|
snapshot.terrain_analysis.as_ref(),
|
||||||
);
|
);
|
||||||
snapshot.layer3 = Some(l3);
|
snapshot.layer3 = Some(l3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,18 +95,27 @@ const MIN_CELL_COST: u32 = RIVER_COST;
|
|||||||
/// edge touching it (the documented T-1116 bug: GJ251c land 0.55 / GJ380c land
|
/// edge touching it (the documented T-1116 bug: GJ251c land 0.55 / GJ380c land
|
||||||
/// 0.588 — land-majority BODIES with 0 routable edges).
|
/// 0.588 — land-majority BODIES with 0 routable edges).
|
||||||
///
|
///
|
||||||
/// **Known gap this relaxation also happens to cover, but does not fix at the
|
/// **Gap this relaxation also happened to cover, now CLOSED at the source
|
||||||
/// source:** D-211's Phase-4 **synthetic overflow** path
|
/// (T-1206, 2026-07-26):** D-211's Phase-4 **synthetic overflow** path
|
||||||
/// (`attractor_matching.rs::synthetic_attractor`, used when a body has more
|
/// (`attractor_matching.rs::synthetic_attractor`) used to pick a position by
|
||||||
/// cities than real attractors) picks a position by pure grid arithmetic
|
/// pure grid arithmetic (`grid_h/2, grid_w/4` then a fixed spacing-walk) with
|
||||||
/// (`grid_h/2, grid_w/4` then a fixed spacing-walk) with **no terrain check at
|
/// **no terrain check at all** — a synthetic-overflow settlement could land
|
||||||
/// all** — it takes no heightmap/`TerrainAnalysis` argument and never reads
|
/// in open ocean (confirmed on 46 real bodies at world seed 42/"yolo" before
|
||||||
/// `ocean_mask`. A synthetic-overflow settlement CAN land in open ocean. This
|
/// the fix). `synthetic_attractor` now takes an `Option<&TerrainAnalysis>`
|
||||||
/// routing relaxation still degrades that case gracefully (anchors to the
|
/// and, when supplied, guards the arithmetic position: already-land stays
|
||||||
/// nearest passable cell within `COASTAL_ANCHOR_MAX_RING`, or leaves it
|
/// byte-identical (verified unmoved on all 63 real land-arithmetic
|
||||||
/// unrouted beyond that), but the placement itself is not guaranteed land —
|
/// placements), water gets nudged to the nearest land cell via a
|
||||||
/// that gap belongs to D-211/Layer 3, not this file, and is reported rather
|
/// deterministic ring-walk (`nearest_land_cell`, the same tie-break pattern
|
||||||
/// than silently patched over here (flagged in PR #215 review, not yet ticketed).
|
/// as this file's own `nearest_passable_cell` below, at native/working-grid
|
||||||
|
/// resolution rather than the routing grid's downsample), and a city with no
|
||||||
|
/// land within the bounded search radius is SKIPPED entirely (never
|
||||||
|
/// fabricated, never a panic — reported via the existing Phase-5
|
||||||
|
/// name-fulfillment warning). This routing relaxation still degrades
|
||||||
|
/// gracefully for the coastal-cell/downsample case it was built for (anchors
|
||||||
|
/// to the nearest passable cell within `COASTAL_ANCHOR_MAX_RING`, or leaves
|
||||||
|
/// it unrouted beyond that) — the two fixes are independent and both apply
|
||||||
|
/// (T-1206 guarantees the placement pixel is land; this relaxation still
|
||||||
|
/// covers the routing CELL being water-majority at downsample granularity).
|
||||||
///
|
///
|
||||||
/// The fix anchors routing to the NEAREST passable routing cell (ring-expansion
|
/// The fix anchors routing to the NEAREST passable routing cell (ring-expansion
|
||||||
/// search, deterministic tie-break) rather than the settlement's own impassable
|
/// search, deterministic tie-break) rather than the settlement's own impassable
|
||||||
|
|||||||
Reference in New Issue
Block a user