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:
2026-07-26 15:27:43 +02:00
co-authored by Claude Fable 5
parent 47279184d2
commit dbd5c6c4f2
4 changed files with 476 additions and 19 deletions
+2
View File
@@ -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.
**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
- **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:
+439 -5
View File
@@ -17,7 +17,7 @@
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::simulation::generator::{
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, SubBiomeVariant,
@@ -267,7 +267,109 @@ fn hungarian(cost: &[Vec<i64>]) -> Vec<usize> {
/// Minimum pixel separation between synthetic attractor positions.
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 (3595%
/// 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.
let mut row = (grid_h / 2) 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),
attractor_type: AttractorType::PlainCenter,
strength: 50,
sub_biome: SubBiomeVariant::TemperateGrassland,
terrain_modification_cost: 100,
water_bearing: NO_WATER_BEARING, // inland synthetic — no water direction
}
})
}
// ---------------------------------------------------------------------------
@@ -335,6 +443,12 @@ fn city_character(
(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(
cities: &[CityRecord],
attractors: &[GeographicAttractor],
@@ -344,6 +458,7 @@ pub fn match_cities(
grid_h: u32,
territorial_status: &TerritorialStatus,
seed: SeedChain,
terrain: Option<&TerrainAnalysis>,
) -> Vec<CityPlacement> {
let default_cost = vec![100i32; attractors.len()];
let costs = terrain_costs.unwrap_or(&default_cost);
@@ -497,7 +612,14 @@ pub fn match_cities(
if placed_ids.contains(&city.city_id) {
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);
flag_mismatch(&city.name, score);
let (archetype, pattern, orientation) = city_character(
@@ -743,6 +865,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].city_id, 1);
@@ -777,6 +900,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 3);
@@ -818,6 +942,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
@@ -841,6 +966,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 2);
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
@@ -877,6 +1003,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
let major = placements.iter().find(|p| p.city_id == 1).unwrap();
let minor = placements.iter().find(|p| p.city_id == 2).unwrap();
@@ -908,6 +1035,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
let hero = placements2.iter().find(|p| p.city_id == 3).unwrap();
assert_eq!(
@@ -936,6 +1064,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 5, "all cities must be placed");
}
@@ -981,6 +1110,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 2);
let farm = placements.iter().find(|p| p.city_id == 1).unwrap();
@@ -1119,4 +1249,308 @@ mod tests {
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));
}
}
+14 -2
View File
@@ -196,6 +196,8 @@ impl CascadeSnapshot {
///
/// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed`
/// 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(
attractors: &[GeographicAttractor],
cities: &[CityRecord],
@@ -203,6 +205,7 @@ fn run_layer3(
seed: SeedChain,
grid_w: u32,
grid_h: u32,
terrain: Option<&TerrainAnalysis>,
) -> Layer3Output {
let matrix = CompatibilityMatrix::d195();
let placements = match_cities(
@@ -214,6 +217,7 @@ fn run_layer3(
grid_h,
territorial_status,
seed,
terrain,
);
Layer3Output { placements }
}
@@ -321,8 +325,15 @@ pub fn run_cascade_from_heightmap(
None => &[],
};
// cache seam: run_layer3 is a pure, deterministic function of
// (attractors, cities, territorial_status, seed) — wrap a persistent
// cache here when we add one (build-time bake or local cache; see #1021).
// (attractors, cities, territorial_status, seed, terrain) — wrap a
// 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(
attractors,
cities,
@@ -330,6 +341,7 @@ pub fn run_cascade_from_heightmap(
body_seed,
snapshot.heightmap.width,
snapshot.heightmap.height,
snapshot.terrain_analysis.as_ref(),
);
snapshot.layer3 = Some(l3);
}
+21 -12
View File
@@ -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
/// 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).
/// **Gap this relaxation also happened to cover, now CLOSED at the source
/// (T-1206, 2026-07-26):** D-211's Phase-4 **synthetic overflow** path
/// (`attractor_matching.rs::synthetic_attractor`) used to pick a position by
/// pure grid arithmetic (`grid_h/2, grid_w/4` then a fixed spacing-walk) with
/// **no terrain check at all** — a synthetic-overflow settlement could land
/// in open ocean (confirmed on 46 real bodies at world seed 42/"yolo" before
/// the fix). `synthetic_attractor` now takes an `Option<&TerrainAnalysis>`
/// and, when supplied, guards the arithmetic position: already-land stays
/// byte-identical (verified unmoved on all 63 real land-arithmetic
/// placements), water gets nudged to the nearest land cell via a
/// deterministic ring-walk (`nearest_land_cell`, the same tie-break pattern
/// 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
/// search, deterministic tie-break) rather than the settlement's own impassable