feat(@projects/@magic-civilization): add climate tile data access

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 19:47:41 -04:00
parent 55550e48e7
commit 7fe43667b8
3 changed files with 147 additions and 0 deletions

View file

@ -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 ────────────────────────────────────────────────────

View file

@ -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<String> {
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.

View file

@ -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, &params);
derive_climate_fields(&mut g2, &params);
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, &params);
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, &params);
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}");
}