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
+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);
}