From f81622bbf0126b76b90da3fbaf2a4dab9b7f9068 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 18:58:53 +0200 Subject: [PATCH] =?UTF-8?q?test(simulation):=20Phase-4=20hardening=20?= =?UTF-8?q?=E2=80=94=20deferred=20#953/#963=20review=20gaps=20(T-964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verified-still-open coverage list: per-type attractor reachability fixtures (LakeShore via enclosed depression, PassEntrance via crafted saddle, PlainCenter via flat terrain, RiverCrossing via confluence) plus thin_by_spacing behavior (collision, strict-< boundary, equirectangular column wrap); heightmap 8-bit decode, sea_level passthrough, downsample identity and zero-target early-return; drainage area_pct bit-for-bit determinism plus the isolated-basin-fallback divergence comment (Tyre N1, citing the pre-#953 behavior it deliberately departs from); the layer1 mountain-branch pairing test (investigated first — the cascade test supplies a mountain pool but only ever asserted river counts, a genuine gap); an importer idempotency test covering atlas_city_names AND atlas_feature_names plus the Sol exemption, wired into make test-tooling; and the oasis_water dilation radius scaled by GRID_W/512 (Tyre N2, hash-stable). One stale item dropped per the refinement trim (test_sim_determinism wiring — already done). Co-Authored-By: Claude Fable 5 --- Makefile | 3 + server/src/atlas/drainage.rs | 54 ++++- server/src/atlas/features.rs | 234 +++++++++++++++++++ server/src/atlas/heightmap.rs | 83 +++++++ server/src/atlas/layer1.rs | 81 +++++++ tooling/economy-db/test_atlas_idempotency.py | 157 +++++++++++++ tooling/planet-gen/planet_simulation.py | 17 +- 7 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 tooling/economy-db/test_atlas_idempotency.py diff --git a/Makefile b/Makefile index 4ecf1a2e4..5fee0f5ec 100644 --- a/Makefile +++ b/Makefile @@ -263,6 +263,9 @@ test-tooling: @mkdir -p .cache @python3 tooling/economy-db/test_traits.py 2> .cache/test-tooling-traits.log || \ { echo " FAIL: traits validation units — log follows:"; cat .cache/test-tooling-traits.log; exit 1; } + @echo " [test-tooling] atlas_city_names/atlas_feature_names idempotency (T-964)..." + @python3 tooling/economy-db/test_atlas_idempotency.py 2> .cache/test-tooling-atlas-idempotency.log || \ + { echo " FAIL: atlas name-pool idempotency — log follows:"; cat .cache/test-tooling-atlas-idempotency.log; exit 1; } @echo " [test-tooling] import_economics --dry-run (committed DB)..." @rc=0; python3 tooling/economy-db/import_economics.py --dry-run \ > .cache/test-tooling-dryrun.log 2>&1 || rc=$$?; \ diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index 7e76745db..89c2d3d7f 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -651,7 +651,28 @@ fn merge_small_basins( best = rep; } } - // No neighbor (isolated basin) → merge into the next smallest active. + // No adjacent neighbor (a fully isolated basin, e.g. a land patch + // wholly surrounded by ocean with no shared D8 edge to any other + // active basin) → merge into whatever OTHER basin is still active + // (BTreeSet iteration order = ascending id, so this picks the lowest + // active id that isn't `smallest` itself). + // + // **Intentional divergence from the pre-#953 behavior (Tyre N1, + // T-964):** the old O(merges × n) implementation (`find_largest_neighbor`, + // see git history at 7cf761434) used `nbr_id.unwrap_or(0)` — an + // isolated basin with no neighbor merged unconditionally into literal + // basin id 0, regardless of whether basin 0 was itself active, nearby, + // or already merged away. That was never a deliberate design choice, + // just the fallback the size-map lookup happened to default to. + // Falling back to basin 0 unconditionally can violate the size-adjacency + // determinism contract this function documents above (smallest-by- + // `(size, id)`, largest-neighbor-by-`(size, lowest id)`): it merges an + // isolated basin into an arbitrary fixed id rather than into the + // basin-graph's own remaining active set. This rewrite instead merges + // into the lowest still-active id (excluding `smallest`) — never + // literal basin 0 unless 0 happens to be that id — keeping the merge + // target inside the same active/adjacency bookkeeping this function + // already maintains, rather than reaching for an unrelated constant. let merge_into = if best >= 0 { best } else { @@ -951,6 +972,37 @@ mod tests { assert!(rf.max_accumulation >= 1); } + #[test] + fn basin_area_pct_is_bit_for_bit_deterministic() { + // `determinism()` above only checks basin COUNT matches across runs; + // this pins the actual `area_pct` f32 VALUES bit-for-bit (D-010) — + // `area_pct = cells.len() as f32 / n as f32` is a pure integer-ratio + // computation, so repeated runs on identical input must produce the + // exact same bit pattern per basin, not merely "close enough". + let elev = slope_grid(128, 64); + let r1 = analyze(&elev, 128, 64, 0.3); + let r2 = analyze(&elev, 128, 64, 0.3); + assert_eq!( + r1.drainage_basins.len(), + r2.drainage_basins.len(), + "fixture sanity: basin count itself must match before comparing area_pct" + ); + for (b1, b2) in r1.drainage_basins.iter().zip(r2.drainage_basins.iter()) { + assert_eq!( + b1.basin_id, b2.basin_id, + "basin ids must be assigned in the same order across runs" + ); + assert_eq!( + b1.area_pct.to_bits(), + b2.area_pct.to_bits(), + "basin {} area_pct must be bit-for-bit identical across runs: {} vs {}", + b1.basin_id, + b1.area_pct, + b2.area_pct + ); + } + } + #[test] fn isolated_basins_no_panic() { // Two land patches split by an ocean band (rows 3-4 below sea level): diff --git a/server/src/atlas/features.rs b/server/src/atlas/features.rs index d3992a9e5..b81b7917f 100644 --- a/server/src/atlas/features.rs +++ b/server/src/atlas/features.rs @@ -900,4 +900,238 @@ mod tests { "RiverMouth attractors must survive the cap when mouths exist" ); } + + // ----------------------------------------------------------------------- + // Per-type attractor reachability (T-964): crafted heightmaps that + // guarantee at least one attractor of the named type, isolating each + // extraction branch instead of relying on the slope/sine fixtures above + // (which reliably exercise RiverMouth/CoastalAccess/ValleyFloor, but never + // guarantee LakeShore/PassEntrance/PlainCenter/RiverCrossing). + // ----------------------------------------------------------------------- + + #[test] + fn lake_shore_reachable_via_enclosed_depression() { + // Two separate below-sea-level components: a wide strip along the + // west edge (the largest — becomes ocean) and a small isolated pit + // elsewhere (smaller — becomes an enclosed lake, D-209/compute_lake_mask). + // Land cells 8-adjacent to the pit must classify LakeShore. + let (w, h) = (32usize, 16usize); + let mut data = vec![0.6f32; w * h]; + for r in 0..h { + for c in 0..4 { + data[r * w + c] = 0.1; // wide ocean strip + } + } + for r in 6..8 { + for c in 16..18 { + data[r * w + c] = 0.1; // small isolated pit, far from the ocean + } + } + let heightmap = hm(data, w as u32, h as u32, 0.3); + let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.3); + let ta = TerrainAnalysis::analyze(&heightmap, &dr); + assert!( + ta.lake_mask.iter().any(|&x| x), + "fixture sanity: the isolated pit must register as a lake, not ocean" + ); + let a = extract_attractors(&heightmap, &dr, &ta); + assert!( + a.iter() + .any(|x| x.attractor_type == AttractorType::LakeShore), + "land adjacent to an enclosed lake must classify LakeShore" + ); + } + + #[test] + fn pass_entrance_reachable_via_morphological_saddle() { + // Classic saddle: the 8-ring around the center alternates high/low + // going clockwise (N,NE,E,SE,S,SW,W,NW), giving 8 sign transitions + // (is_saddle requires >= 4). Whole grid is high-elevation land so the + // saddle's elev_pct clears the >= 0.5 PassEntrance gate. + let (w, h) = (32usize, 16usize); + let (cr, cc) = (h / 2, w / 2); + let mut data = vec![0.7f32; w * h]; + data[cr * w + cc] = 0.75; // the saddle point itself + const RING: [(i32, i32); 8] = [ + (-1, 0), + (-1, 1), + (0, 1), + (1, 1), + (1, 0), + (1, -1), + (0, -1), + (-1, -1), + ]; + let ring_vals = [0.95, 0.55, 0.95, 0.55, 0.95, 0.55, 0.95, 0.55]; + for (k, &(dr_off, dc_off)) in RING.iter().enumerate() { + let rr = (cr as i32 + dr_off) as usize; + let cc_ = (cc as i32 + dc_off) as usize; + data[rr * w + cc_] = ring_vals[k]; + } + let heightmap = hm(data, w as u32, h as u32, 0.0); + let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.0); + let ta = TerrainAnalysis::analyze(&heightmap, &dr); + assert!( + ta.elev_pct[cr * w + cc] >= 0.5, + "fixture sanity: saddle point must clear the PassEntrance elev_pct gate" + ); + let a = extract_attractors(&heightmap, &dr, &ta); + assert!( + a.iter() + .any(|x| x.attractor_type == AttractorType::PassEntrance), + "a genuine morphological saddle at high elevation must classify PassEntrance" + ); + } + + #[test] + fn plain_center_reachable_via_flat_uniform_terrain() { + // A uniformly flat, non-ocean grid: slope_deg is 0 everywhere (well + // under the < 2.0 PlainCenter gate), so at least one cell survives + // thin_by_spacing as PlainCenter even where ValleyFloor also + // competes for the uniform elev_pct=0.5 rank tie. + let (w, h) = (32usize, 16usize); + let data = vec![0.9f32; w * h]; + let heightmap = hm(data, w as u32, h as u32, 0.0); + let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.0); + let ta = TerrainAnalysis::analyze(&heightmap, &dr); + let a = extract_attractors(&heightmap, &dr, &ta); + assert!( + a.iter() + .any(|x| x.attractor_type == AttractorType::PlainCenter), + "flat, non-ocean terrain must produce at least one PlainCenter attractor" + ); + } + + #[test] + fn river_crossing_reachable_via_confluence() { + // Two V-shaped tributary valleys (west + east branches) converge into + // a single trunk valley at (confluence_row, confluence_col) — the + // trunk cell has 2+ river-cell inflows, so `drainage::analyze` must + // report it as a confluence (drainage.rs's own D8 confluence rule), + // and extract_attractors must tag it RiverCrossing. + let (w, h) = (64usize, 64usize); + let confluence_col = (w / 2) as f32; + let confluence_row = (h / 2) as f32; + let data: Vec = (0..(w * h)) + .map(|i| { + let r = (i / w) as f32; + let c = (i % w) as f32; + if r <= confluence_row { + // Upstream: two separate branches either side of the + // confluence column, each sloping down toward it. + let branch_center = if c < confluence_col { + confluence_col * 0.5 + } else { + confluence_col * 1.5 + }; + let lateral = (c - branch_center).abs() / w as f32; + let downstream = (confluence_row - r) / h as f32; + (0.3 + lateral * 1.5 - downstream * 0.4).clamp(0.0, 1.0) + } else { + // Downstream: single widening trunk valley. + let lateral = (c - confluence_col).abs() / w as f32; + let downstream = (r - confluence_row) / h as f32; + (0.3 + lateral * 1.5 - downstream * 0.6).clamp(0.0, 1.0) + } + }) + .collect(); + let heightmap = hm(data, w as u32, h as u32, 0.0); + let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.0); + assert!( + !dr.river_network.confluences.is_empty(), + "fixture sanity: the converging-tributary fixture must produce a confluence" + ); + let ta = TerrainAnalysis::analyze(&heightmap, &dr); + let a = extract_attractors(&heightmap, &dr, &ta); + assert!( + a.iter() + .any(|x| x.attractor_type == AttractorType::RiverCrossing), + "a genuine D8 confluence must classify RiverCrossing" + ); + } + + // ----------------------------------------------------------------------- + // thin_by_spacing behavior (T-964): spacing collisions + equirectangular + // column wrap. + // ----------------------------------------------------------------------- + + #[test] + fn thin_by_spacing_drops_close_candidates_keeps_strongest() { + // Three candidates within MIN_SPACING (12) of each other: only the + // strongest should survive; a fourth, far-away candidate is + // independent and must survive alongside it. + let claimed = vec![false; 64 * 64]; + let cands = vec![ + (10usize, 10usize, 0.5f32), + (10usize, 15usize, 0.9f32), // strongest, within spacing of the other two + (15usize, 10usize, 0.3f32), + (50usize, 50usize, 0.4f32), // far away — independent, must survive + ]; + let kept = thin_by_spacing(cands, &claimed, 64); + assert_eq!( + kept.len(), + 2, + "expected exactly 2 survivors (the strongest of the clustered trio + the \ + far-away independent point), got {kept:?}" + ); + assert!( + kept.contains(&(10, 15, 0.9)), + "the strongest candidate in the cluster must survive: {kept:?}" + ); + assert!( + kept.contains(&(50, 50, 0.4)), + "the far-away independent candidate must survive: {kept:?}" + ); + } + + #[test] + fn thin_by_spacing_respects_exact_spacing_boundary() { + // Chebyshev distance exactly MIN_SPACING (12) apart must NOT collide + // (the check is `dr.max(dc) < MIN_SPACING`, a strict less-than) — both + // survive. One cell short of that (11) must collide — only the + // stronger survives. + let claimed = vec![false; 64 * 64]; + + let at_boundary = vec![(0usize, 0usize, 0.5f32), (12usize, 0usize, 0.5f32)]; + let kept_boundary = thin_by_spacing(at_boundary, &claimed, 64); + assert_eq!( + kept_boundary.len(), + 2, + "cells exactly MIN_SPACING apart must both survive (strict <): {kept_boundary:?}" + ); + + let inside_spacing = vec![(0usize, 0usize, 0.5f32), (11usize, 0usize, 0.9f32)]; + let kept_inside = thin_by_spacing(inside_spacing, &claimed, 64); + assert_eq!( + kept_inside.len(), + 1, + "cells 1 short of MIN_SPACING must collide, keeping only the stronger: \ + {kept_inside:?}" + ); + assert_eq!(kept_inside[0], (11, 0, 0.9)); + } + + #[test] + fn thin_by_spacing_column_wrap_does_not_collide_across_the_seam() { + // thin_by_spacing itself is a pure Chebyshev-distance thinner over + // (row, col) pairs — it has NO knowledge of the equirectangular + // column wrap (unlike NB8-based neighbor walks elsewhere in this + // file, which wrap explicitly via `wrap_col`). Two candidates at + // opposite ends of a wide grid (col 0 and col w-1) are geographically + // adjacent on the globe but numerically far apart in (row, col) + // space, so thin_by_spacing must NOT treat them as colliding — both + // survive. This pins the current (non-wrap-aware) behavior so a + // future change to make thinning wrap-aware is a deliberate, + // visible decision, not a silent behavior drift. + let w = 64usize; + let claimed = vec![false; w * 64]; + let cands = vec![(5usize, 0usize, 0.5f32), (5usize, w - 1, 0.6f32)]; + let kept = thin_by_spacing(cands, &claimed, w); + assert_eq!( + kept.len(), + 2, + "column-wrap-adjacent candidates are numerically far apart in (row, col) \ + space — thin_by_spacing must not collide them: {kept:?}" + ); + } } diff --git a/server/src/atlas/heightmap.rs b/server/src/atlas/heightmap.rs index a29914249..9646ace52 100644 --- a/server/src/atlas/heightmap.rs +++ b/server/src/atlas/heightmap.rs @@ -247,6 +247,89 @@ mod tests { assert!(matches!(err, HeightmapLoadError::UnsupportedFormat { .. })); } + /// Encode an 8-bit grayscale PNG (row-major u8 elevation) to bytes. + fn encode_gray8(w: u32, h: u32, vals: &[u8]) -> Vec { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, w, h); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::Eight); + let mut writer = enc.write_header().unwrap(); + writer.write_image_data(vals).unwrap(); + } + out + } + + #[test] + fn decodes_8bit_grayscale_as_coarse_viewable_test_format() { + // 8-bit grayscale is accepted per the module doc — "coarse — + // viewable/test only" — normalized the same way as 16-bit (byte / + // 255.0), distinct from the 16-bit path's `/ 65535.0` divisor. + let (w, h) = (4u32, 1u32); + let vals: Vec = vec![0, 85, 170, 255]; // 0, 1/3, 2/3, 1.0 + let png_bytes = encode_gray8(w, h, &vals); + let hm = load_heightmap_reader(png_bytes.as_slice(), "T", 0.3).unwrap(); + assert_eq!((hm.width, hm.height), (w, h)); + assert_eq!(hm.data.len(), 4); + assert!((hm.data[0] - 0.0).abs() < 1e-6); + assert!((hm.data[1] - 1.0 / 3.0).abs() < 1e-3); + assert!((hm.data[2] - 2.0 / 3.0).abs() < 1e-3); + assert!((hm.data[3] - 1.0).abs() < 1e-6); + assert!(hm.is_land(0, 3)); // 1.0 > sea 0.3 + assert!(!hm.is_land(0, 0)); // 0.0 < sea 0.3 + } + + #[test] + fn sea_level_passes_through_default_when_no_text_chunk() { + // No `sea_level` tEXt chunk written — the loader must fall back to + // exactly the caller-supplied `default_sea_level`, unmodified + // (the mirror case of `reads_sea_level_from_text_chunk`, which + // covers the chunk-present override path). + let png_bytes = encode_gray16(2, 1, &[0, 65535]); + let hm = load_heightmap_reader(png_bytes.as_slice(), "T", 0.42).unwrap(); + assert!( + (hm.sea_level - 0.42).abs() < 1e-6, + "sea_level must pass through the supplied default unmodified, got {}", + hm.sea_level + ); + } + + #[test] + fn downsample_to_larger_or_equal_target_returns_identity_clone() { + // `target_w >= self.width && target_h >= self.height` short-circuits + // to a clone (no upsampling) — exercise both the exact-equal case and + // the strictly-larger case. + let vals: Vec = vec![0, 21845, 43690, 65535]; // 0, 1/3, 2/3, 1.0 + let hm = load_heightmap_reader(encode_gray16(2, 2, &vals).as_slice(), "T", 0.3).unwrap(); + + let same = hm.downsample(2, 2); + assert_eq!((same.width, same.height), (2, 2)); + assert_eq!(same.data, hm.data, "equal-size downsample must be an identity clone"); + + let larger = hm.downsample(8, 8); + assert_eq!( + (larger.width, larger.height), + (2, 2), + "downsample must early-return the original dims (no upsampling) when the \ + target is larger than the source" + ); + assert_eq!(larger.data, hm.data, "upsample request must be an identity clone"); + } + + #[test] + fn downsample_zero_target_returns_identity_clone() { + // `target_w == 0 || target_h == 0` is the other early-return branch — + // a degenerate target dimension must not panic or divide by zero. + let vals: Vec = vec![0, 65535, 32768, 16384]; + let hm = load_heightmap_reader(encode_gray16(2, 2, &vals).as_slice(), "T", 0.3).unwrap(); + let zero_w = hm.downsample(0, 4); + assert_eq!((zero_w.width, zero_w.height), (2, 2)); + assert_eq!(zero_w.data, hm.data); + let zero_h = hm.downsample(4, 0); + assert_eq!((zero_h.width, zero_h.height), (2, 2)); + assert_eq!(zero_h.data, hm.data); + } + #[test] fn reads_sea_level_from_text_chunk() { // The bake writes sea_level as a tEXt chunk; the loader must prefer it diff --git a/server/src/atlas/layer1.rs b/server/src/atlas/layer1.rs index 7561ea369..15da83e2c 100644 --- a/server/src/atlas/layer1.rs +++ b/server/src/atlas/layer1.rs @@ -461,6 +461,87 @@ mod tests { assert!(rivers.len() <= names.len()); } + /// T-964 layer1.rs mountain-branch coverage: `attach_feature_names`'s + /// mountain half (pairing `Alpine` sub-biome attractors with + /// `mountain_names`, per its own doc) was only exercised indirectly by + /// `cascade::tests::topography_layer_attaches_feature_names_when_pools_supplied`, + /// which asserts exclusively on RIVER assignment counts — the mountain + /// pool there has a single entry and its actual pairing is never checked. + /// This test constructs a heightmap guaranteed to produce a real `Alpine` + /// attractor (`subbiome::classify_variant`'s `elev_pct > 0.80` gate) and + /// asserts the mountain half of `attach_feature_names` actually pairs it. + #[test] + fn attach_feature_names_pairs_alpine_attractor_with_mountain_pool() { + // A morphological saddle (PassEntrance, features.rs's own is_saddle + // ring test) pushed to a high absolute elevation, so the saddle point + // itself clears BOTH the PassEntrance elev_pct>=0.5 gate AND the + // Alpine sub-biome elev_pct>0.80 gate (subbiome::classify_variant). + // A PassEntrance candidate's strength (`1 - elev_pct`) is ranked + // independent of the ValleyFloor/PlainCenter habitability contest + // (features::habitability penalizes extreme elevation) — the saddle + // is guaranteed a dedicated, single-cell candidate, unlike a flat + // high plateau (whose interior loses the thin_by_spacing contest to + // nearby low-elevation flat cells competing on habitability, since a + // plateau spanning a single MIN_SPACING bucket produces exactly one + // surviving candidate per bucket — verified empirically before + // settling on this saddle-based fixture instead). + let (w, h) = (32u32, 16u32); + let (cr, cc) = (h as usize / 2, w as usize / 2); + let mut data = vec![0.85f32; (w * h) as usize]; + data[cr * w as usize + cc] = 0.9; // the saddle point + const RING: [(i32, i32); 8] = [ + (-1, 0), + (-1, 1), + (0, 1), + (1, 1), + (1, 0), + (1, -1), + (0, -1), + (-1, -1), + ]; + let ring_vals = [0.99, 0.75, 0.99, 0.75, 0.99, 0.75, 0.99, 0.75]; + for (k, &(dr_off, dc_off)) in RING.iter().enumerate() { + let rr = (cr as i32 + dr_off) as usize; + let cc_ = (cc as i32 + dc_off) as usize; + data[rr * w as usize + cc_] = ring_vals[k]; + } + let heightmap = BodyHeightmap { + body_id: "AlpineBody".into(), + width: w, + height: h, + data, + sea_level: 0.0, + }; + let (o, _ta) = run_layer1(&heightmap); + assert!( + o.attractors + .iter() + .any(|a| a.sub_biome == crate::simulation::generator::SubBiomeVariant::Alpine), + "fixture sanity: the high-elevation saddle must produce at least one \ + Alpine attractor — got {:?}", + o.attractors + .iter() + .map(|a| (a.position, a.attractor_type, a.sub_biome)) + .collect::>() + ); + + let river_names: Vec = Vec::new(); + let mountain_names = vec!["Wiesenbach".to_string(), "Kaltgrat".to_string()]; + let (rivers, mountains) = attach_feature_names(&o, &river_names, &mountain_names); + assert!(rivers.is_empty(), "no river-name pool was supplied"); + assert!( + !mountains.is_empty(), + "an Alpine attractor with a non-empty mountain-name pool must yield at \ + least one mountain assignment" + ); + assert!( + mountains + .iter() + .all(|(_, name)| mountain_names.contains(name)), + "assigned mountain names must come from the supplied pool: {mountains:?}" + ); + } + // ------------------------------------------------------------------- // T-1184 — hydrology productionization // ------------------------------------------------------------------- diff --git a/tooling/economy-db/test_atlas_idempotency.py b/tooling/economy-db/test_atlas_idempotency.py new file mode 100644 index 000000000..349075a6d --- /dev/null +++ b/tooling/economy-db/test_atlas_idempotency.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +Idempotency tests for economy_import.atlas's names-only pool populators +(T-964 — Phase-4 test hardening). + +`populate_atlas_city_names` and `populate_atlas_feature_names` both clear +their target table before reinserting (see each function's own docstring: +"there is no UNIQUE(body_id, name[, feature_type]) — without the clear a +re-run would accumulate duplicates"). This test proves that contract holds in +practice: two back-to-back runs against the same DB must yield an IDENTICAL +row count, not a doubled one, and Sol ('GJ 0') must remain permanently +exempt (D-223) on both runs. + +Runs against a scratch COPY of the committed `server/data/systems.db` (real +schema + real `bodies`/FK data — not a hand-rolled in-memory schema, which +would drift from the real FK web this populator depends on). Wiki content +(`wiki/star-systems/*/bodies/*/markers.json`) is read directly from the repo +— read-only input, safe to reuse as-is; only the DB connection is scratch. +Deliberately calls the two populator functions directly rather than the full +`import_economics.py` CLI: the CLI's first step shells out to the Rust +`generate_brands` binary and overwrites `generated_brands.toml` on disk +(`brands.regenerate_brands`), a real side effect on shared repo content that +has nothing to do with atlas name-pool idempotency and would make this test +depend on a Rust build. + +Stdlib only (unittest) — run directly or via `make test-tooling`: + python3 tooling/economy-db/test_atlas_idempotency.py +""" + +import shutil +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from economy_import import atlas # noqa: E402 +from economy_import.paths import DB_PATH # noqa: E402 + + +class AtlasNamePoolIdempotencyTests(unittest.TestCase): + """Two runs on a scratch DB copy must yield stable, non-doubled counts.""" + + @classmethod + def setUpClass(cls): + if not DB_PATH.exists(): + raise unittest.SkipTest(f"committed systems.db not found at {DB_PATH}") + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + scratch_path = Path(self._tmp.name) / "systems_scratch.db" + shutil.copyfile(DB_PATH, scratch_path) + self.conn = sqlite3.connect(str(scratch_path)) + self.conn.execute("PRAGMA foreign_keys=ON") + + def tearDown(self): + self.conn.close() + self._tmp.cleanup() + + def test_atlas_city_names_count_stable_across_two_runs(self): + first = atlas.populate_atlas_city_names(self.conn, dry_run=False) + first_count = self.conn.execute( + "SELECT COUNT(*) FROM atlas_city_names" + ).fetchone()[0] + self.assertEqual( + first, first_count, "populator return value must match the rows it wrote" + ) + + second = atlas.populate_atlas_city_names(self.conn, dry_run=False) + second_count = self.conn.execute( + "SELECT COUNT(*) FROM atlas_city_names" + ).fetchone()[0] + + self.assertEqual( + first_count, + second_count, + "a second run must yield an IDENTICAL atlas_city_names count, not " + "a doubled one — the clear-before-reinsert contract must hold", + ) + self.assertEqual( + first, second, "the populator's own return value must also be stable" + ) + self.assertGreater( + first_count, 0, "fixture sanity: the committed wiki content must yield rows" + ) + + def test_atlas_feature_names_count_stable_across_two_runs(self): + # T-1169: atlas_feature_names landed alongside atlas_city_names but + # is a separate table/populator — must be checked independently, not + # assumed to share the city-name populator's idempotency by proximity. + first = atlas.populate_atlas_feature_names(self.conn, dry_run=False) + first_count = self.conn.execute( + "SELECT COUNT(*) FROM atlas_feature_names" + ).fetchone()[0] + self.assertEqual(first, first_count) + + second = atlas.populate_atlas_feature_names(self.conn, dry_run=False) + second_count = self.conn.execute( + "SELECT COUNT(*) FROM atlas_feature_names" + ).fetchone()[0] + + self.assertEqual( + first_count, + second_count, + "a second run must yield an IDENTICAL atlas_feature_names count, not " + "a doubled one — the clear-before-reinsert contract must hold", + ) + self.assertEqual(first, second) + self.assertGreater( + first_count, 0, "fixture sanity: the committed wiki content must yield rows" + ) + + def test_sol_system_gj0_gets_zero_rows_in_both_pools_across_two_runs(self): + # D-223 permanent exemption: Sol ('GJ 0') keeps authored, + # geometry-bearing markers.json via sol_import.py — never the names + # pool. Must hold on the FIRST run (not just "never accumulates") and + # remain zero on the second. + for run in (1, 2): + atlas.populate_atlas_city_names(self.conn, dry_run=False) + atlas.populate_atlas_feature_names(self.conn, dry_run=False) + + sol_cities = self.conn.execute( + """SELECT COUNT(*) FROM atlas_city_names + WHERE body_id IN (SELECT body_id FROM bodies WHERE system_id = 'GJ 0')""" + ).fetchone()[0] + sol_features = self.conn.execute( + """SELECT COUNT(*) FROM atlas_feature_names + WHERE body_id IN (SELECT body_id FROM bodies WHERE system_id = 'GJ 0')""" + ).fetchone()[0] + + self.assertEqual( + sol_cities, 0, f"run {run}: Sol (GJ 0) must have zero atlas_city_names rows" + ) + self.assertEqual( + sol_features, + 0, + f"run {run}: Sol (GJ 0) must have zero atlas_feature_names rows", + ) + + # Fixture sanity: GJ 0 must actually have bodies in this DB, or the + # zero-rows assertions above would be vacuously true. + gj0_body_count = self.conn.execute( + "SELECT COUNT(*) FROM bodies WHERE system_id = 'GJ 0'" + ).fetchone()[0] + self.assertGreater( + gj0_body_count, + 0, + "fixture sanity: GJ 0 (Sol) must have bodies in the committed DB, or " + "the exemption checks above are vacuous", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tooling/planet-gen/planet_simulation.py b/tooling/planet-gen/planet_simulation.py index 15700a73d..df11b92d9 100644 --- a/tooling/planet-gen/planet_simulation.py +++ b/tooling/planet-gen/planet_simulation.py @@ -784,8 +784,21 @@ def compute_biome(body_def, elevation, sea_level, surface_water, oasis_water = (biome == 2) & land # scattered lake cells on land if oasis_water.any(): from scipy.ndimage import binary_dilation - ring1 = binary_dilation(oasis_water, iterations=2) & ~oasis_water & land - ring2 = binary_dilation(oasis_water, iterations=4) & ~oasis_water & ~ring1 & land + # Pixel-unit iteration counts scale by GRID_W/512 (module + # convention, see the GRID_W comment above) so the vegetation + # ring covers the same physical distance at any resolution — a + # fixed pixel count would shrink the ring (relative to the + # planet's real size) as GRID_W grows past the 512 baseline. + scale = GRID_W / 512.0 + ring1_iters = max(1, round(2 * scale)) + ring2_iters = max(1, round(4 * scale)) + ring1 = binary_dilation(oasis_water, iterations=ring1_iters) & ~oasis_water & land + ring2 = ( + binary_dilation(oasis_water, iterations=ring2_iters) + & ~oasis_water + & ~ring1 + & land + ) # Inner ring: lush vegetation (coast/lowland green) biome[ring1] = 4 # lowland # Outer ring: transitional (savanna/shrub)