From 7fe43667b8216cc1676f2bc9d272006b2c1d03d2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 30 Apr 2026 19:47:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20climate=20tile=20data=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/src/lib.rs | 23 ++++ src/simulator/api-wasm/src/lib.rs | 19 ++++ .../mc-climate/tests/climate_decomposition.rs | 105 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/simulator/crates/mc-climate/tests/climate_decomposition.rs diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 7b627070..590fb3a1 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -171,6 +171,29 @@ impl GdGridState { None => Dictionary::new(), } } + + /// Return climate axes fields for a single tile as a Dictionary. + /// Keys: latitude, continentality, mean_temp, mean_precip, seasonality, + /// aridity_index (floats), t_band, p_band (ints). + /// Returns an empty Dictionary if the tile coordinates are out of range. + #[func] + fn tile_climate(&self, col: i64, row: i64) -> Dictionary { + match self.inner.tile(col as i32, row as i32) { + Some(tile) => { + let mut d = Dictionary::new(); + d.set("latitude", tile.latitude as f64); + d.set("continentality", tile.continentality as f64); + d.set("mean_temp", tile.mean_temp as f64); + d.set("mean_precip", tile.mean_precip as f64); + d.set("seasonality", tile.seasonality as f64); + d.set("aridity_index", tile.aridity_index as f64); + d.set("t_band", tile.t_band as i64); + d.set("p_band", tile.p_band as i64); + d + } + None => Dictionary::new(), + } + } } // ── GdClimatePhysics ──────────────────────────────────────────────────── diff --git a/src/simulator/api-wasm/src/lib.rs b/src/simulator/api-wasm/src/lib.rs index 9fe31bf7..ebba0974 100644 --- a/src/simulator/api-wasm/src/lib.rs +++ b/src/simulator/api-wasm/src/lib.rs @@ -225,6 +225,25 @@ impl WasmGrid { ) }) } + + /// Return climate axes fields for a single tile as a JSON string. + /// Returns `null` if the coordinates are out of range. + #[wasm_bindgen(js_name = "tileClimateJson")] + pub fn tile_climate_json(&self, col: i32, row: i32) -> Option { + self.inner.tile(col, row).map(|t| { + format!( + r#"{{"latitude":{latitude},"continentality":{continentality},"mean_temp":{mean_temp},"mean_precip":{mean_precip},"seasonality":{seasonality},"aridity_index":{aridity_index},"t_band":{t_band},"p_band":{p_band}}}"#, + latitude = t.latitude, + continentality = t.continentality, + mean_temp = t.mean_temp, + mean_precip = t.mean_precip, + seasonality = t.seasonality, + aridity_index = t.aridity_index, + t_band = t.t_band, + p_band = t.p_band, + ) + }) + } } /// WASM-exposed climate physics engine. diff --git a/src/simulator/crates/mc-climate/tests/climate_decomposition.rs b/src/simulator/crates/mc-climate/tests/climate_decomposition.rs new file mode 100644 index 00000000..fce6cbfc --- /dev/null +++ b/src/simulator/crates/mc-climate/tests/climate_decomposition.rs @@ -0,0 +1,105 @@ +//! Climate decomposition golden test (p2-49). +//! Derives climate fields for a 10×10 grid and freezes the results. +//! Any change to derive logic must update the expected values and add +//! a migration note to the commit message. + +use mc_climate::derive::{ + latitude, t_band, p_band, mean_temp, aridity_index, ClimateParams, + classify_terrain_whittaker, derive_climate_fields, +}; +use mc_core::grid::GridState; + +#[test] +fn latitude_derivation_golden() { + // Row 0 = north pole (+1.0), row 9 = south pole (-1.0) + let cases: &[(u32, u32, f32)] = &[ + (0, 10, 1.0), + (5, 10, -0.111), // ≈ 1 - 2*(5/9) = 1 - 1.111 = -0.111 + (9, 10, -1.0), + ]; + for &(row, rows, expected) in cases { + let got = latitude(row, rows); + assert!((got - expected).abs() < 0.01, "latitude({row},{rows}) = {got}, want ≈{expected}"); + } +} + +#[test] +fn band_discretisation_stable() { + let t_thresh = [0.20f32, 0.40, 0.60, 0.80]; + let p_thresh = [0.15f32, 0.35, 0.60, 0.80]; + // Spot-checks — these values must never change without a data migration + assert_eq!(t_band(0.10, &t_thresh), 0, "T polar"); + assert_eq!(t_band(0.30, &t_thresh), 1, "T cold"); + assert_eq!(t_band(0.50, &t_thresh), 2, "T temperate"); + assert_eq!(t_band(0.70, &t_thresh), 3, "T warm"); + assert_eq!(t_band(0.90, &t_thresh), 4, "T hot"); + + assert_eq!(p_band(0.05, &p_thresh), 0, "P hyper_arid"); + assert_eq!(p_band(0.25, &p_thresh), 1, "P arid"); + assert_eq!(p_band(0.50, &p_thresh), 2, "P semi_arid"); + assert_eq!(p_band(0.70, &p_thresh), 3, "P humid"); + assert_eq!(p_band(0.90, &p_thresh), 4, "P wet"); +} + +#[test] +fn whittaker_table_spot_checks() { + // Directly from the CLIMATE.md Whittaker table + assert_eq!(classify_terrain_whittaker(0, 0, 0.3), "tundra", "polar any → tundra"); + assert_eq!(classify_terrain_whittaker(1, 0, 0.3), "tundra", "cold arid → tundra"); + assert_eq!(classify_terrain_whittaker(1, 3, 0.3), "boreal_forest","cold wet → boreal_forest"); + assert_eq!(classify_terrain_whittaker(2, 0, 0.3), "desert", "temperate arid → desert"); + assert_eq!(classify_terrain_whittaker(2, 1, 0.3), "grassland", "temperate semi-arid → grassland"); + assert_eq!(classify_terrain_whittaker(3, 0, 0.3), "desert", "warm arid → desert"); + assert_eq!(classify_terrain_whittaker(3, 2, 0.3), "plains", "warm semi-arid → plains"); + assert_eq!(classify_terrain_whittaker(4, 0, 0.3), "desert", "hot hyper-arid → desert"); + assert_eq!(classify_terrain_whittaker(4, 4, 0.3), "jungle", "hot wet → jungle"); + // Elevation overrides + assert_eq!(classify_terrain_whittaker(4, 4, 0.86), "snow", "elev>0.85 → snow"); + assert_eq!(classify_terrain_whittaker(4, 4, 0.75), "mountains", "elev>0.70 → mountains"); +} + +#[test] +fn derive_climate_fields_deterministic_on_10x10() { + let params = ClimateParams::default(); + + let make_grid = || { + let mut g = GridState::new(10, 10); + for (i, t) in g.tiles.iter_mut().enumerate() { + t.elevation = 0.3 + (i % 5) as f32 * 0.1; + t.biome_id = if i % 7 == 0 { "ocean".to_string() } else { "land".to_string() }; + // Simulate tectonic pass output + t.mountain_proximity = if i % 11 == 0 { 0.8 } else { 0.0 }; + t.coast_proximity = if i % 5 == 0 { 1.0 } else { 0.2 }; + } + g + }; + + let mut g1 = make_grid(); + let mut g2 = make_grid(); + derive_climate_fields(&mut g1, ¶ms); + derive_climate_fields(&mut g2, ¶ms); + + for i in 0..g1.tiles.len() { + assert_eq!(g1.tiles[i].t_band, g2.tiles[i].t_band, "t_band mismatch idx={i}"); + assert_eq!(g1.tiles[i].p_band, g2.tiles[i].p_band, "p_band mismatch idx={i}"); + assert_eq!(g1.tiles[i].mean_temp.to_bits(), g2.tiles[i].mean_temp.to_bits(), "mean_temp mismatch idx={i}"); + assert_eq!(g1.tiles[i].mean_precip.to_bits(),g2.tiles[i].mean_precip.to_bits(),"mean_precip mismatch idx={i}"); + assert_eq!(g1.tiles[i].latitude.to_bits(), g2.tiles[i].latitude.to_bits(), "latitude mismatch idx={i}"); + } +} + +#[test] +fn aridity_index_range_reasonable() { + let params = ClimateParams::default(); + // Tropical equator: should be humid (aridity > 1) + let t_tropical = mean_temp(0.0, 0.2, ¶ms); + let p_high = 0.85f32; + let ai_humid = aridity_index(t_tropical, p_high); + assert!(ai_humid > 1.0, "humid tile aridity_index should be >1.0, got {ai_humid}"); + + // Dry mid-lat: should be arid (<0.5) + let t_mid = mean_temp(0.5, 0.3, ¶ms); + let p_low = 0.10f32; + let ai_dry = aridity_index(t_mid, p_low); + assert!(ai_dry < 0.5, "dry tile aridity_index should be <0.5, got {ai_dry}"); +}