diff --git a/src/simulator/api-wasm/Cargo.toml b/src/simulator/api-wasm/Cargo.toml new file mode 100644 index 00000000..2c93e1cf --- /dev/null +++ b/src/simulator/api-wasm/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "magic-civ-physics" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +mc-core = { path = "../crates/mc-core" } +mc-climate = { path = "../crates/mc-climate" } +mc-mapgen = { path = "../crates/mc-mapgen" } +wasm-bindgen = "0.2" +js-sys = "0.3" +serde-wasm-bindgen = "0.6" +getrandom = { version = "0.2", features = ["js"] } +serde.workspace = true +serde_json.workspace = true diff --git a/src/simulator/api-wasm/src/lib.rs b/src/simulator/api-wasm/src/lib.rs new file mode 100644 index 00000000..7fc1be24 --- /dev/null +++ b/src/simulator/api-wasm/src/lib.rs @@ -0,0 +1,301 @@ +/// WASM API surface — exposes Rust simulation to the web guide via wasm-bindgen. +/// All public functions match the signatures specified in Task #2. + +use wasm_bindgen::prelude::*; + +use mc_core::grid::{GridState, biome_registry::{has_tag, BiomeTag}}; +use mc_climate::{ClimatePhysics, EcologyPhysics, step_atmospheric_chemistry}; +use mc_mapgen::MapGenerator; + +/// WASM-exposed grid handle wrapping GridState. +#[wasm_bindgen] +pub struct WasmGrid { + inner: GridState, +} + +#[wasm_bindgen] +impl WasmGrid { + /// Create a new empty grid of given dimensions. + #[wasm_bindgen(constructor)] + pub fn new(width: i32, height: i32) -> Self { + Self { + inner: GridState::new(width, height), + } + } + + /// Serialize the grid to JSON. + #[wasm_bindgen(js_name = "toJSON")] + pub fn to_json(&self) -> Result { + serde_wasm_bindgen::to_value(&self.inner).map_err(|e| JsError::new(&e.to_string())) + } + + /// Deserialize a grid from JSON. + #[wasm_bindgen(js_name = "fromJSON")] + pub fn from_json(val: JsValue) -> Result { + let inner: GridState = serde_wasm_bindgen::from_value(val).map_err(|e| JsError::new(&e.to_string()))?; + Ok(Self { inner }) + } + + #[wasm_bindgen(getter)] + pub fn width(&self) -> i32 { + self.inner.width + } + + #[wasm_bindgen(getter)] + pub fn height(&self) -> i32 { + self.inner.height + } + + #[wasm_bindgen(getter)] + pub fn global_avg_temp(&self) -> f32 { + self.inner.global_avg_temp + } + + #[wasm_bindgen(getter)] + pub fn ocean_dead_fraction(&self) -> f32 { + self.inner.ocean_dead_fraction + } + + #[wasm_bindgen(getter)] + pub fn tile_count(&self) -> usize { + self.inner.tiles.len() + } + + /// Compute aggregate turn stats in Rust and return as a JSON string. + /// This replaces the JS `computeTurnStats(wasmGridToJs(wg))` pattern — avoids + /// serializing all tile data to JS just to aggregate it. + #[wasm_bindgen(js_name = "computeStatsJson")] + pub fn compute_stats_json(&self, prev_avg_temp: f32, prev_avg_moisture: f32) -> String { + let grid = &self.inner; + let tiles = &grid.tiles; + let n = tiles.len(); + let height = grid.height as f32; + + let mut temp_sum = 0.0f32; + let mut moist_sum = 0.0f32; + let mut surface_water_sum = 0.0f32; + let mut albedo_sum = 0.0f32; + let mut solar_sum = 0.0f32; + let mut land_canopy_sum = 0.0f32; + let mut land_undergrowth_sum = 0.0f32; + let mut land_fungi_sum = 0.0f32; + let mut land_habitat_sum = 0.0f32; + let mut land_quality_sum = 0.0f32; + let mut water_reef_sum = 0.0f32; + let mut water_fish_sum = 0.0f32; + let mut water_quality_sum = 0.0f32; + let mut water_count = 0usize; + let mut aerosol_sum = 0.0f32; + let mut et_sum = 0.0f32; + let mut wind_speed_sum = 0.0f32; + let mut land_count = 0usize; + let mut terrain_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + let mut substrate_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + let mut elevation_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + + for tile in tiles { + *terrain_counts.entry(tile.biome_id.as_str()).or_insert(0) += 1; + if !tile.substrate_id.is_empty() { + *substrate_counts.entry(tile.substrate_id.as_str()).or_insert(0) += 1; + } + + let elev_band = if tile.elevation < 0.15 { "abyss" } + else if tile.elevation < 0.30 { "deep_water" } + else if tile.elevation < 0.45 { "shallow_water" } + else if tile.elevation < 0.55 { "lowland" } + else if tile.elevation < 0.70 { "midland" } + else if tile.elevation < 0.85 { "highland" } + else { "peak" }; + *elevation_counts.entry(elev_band).or_insert(0) += 1; + + let is_water = has_tag(&tile.biome_id, BiomeTag::IsWater); + + // Albedo calculation matching computeTileAlbedo() in runner.ts + let albedo = if tile.temperature < 0.1 { + let t = (tile.temperature / 0.1).clamp(0.0, 1.0); + let base = if is_water { 0.06 } else { 0.25 }; + 0.80 + t * (base - 0.80) + } else if is_water { + 0.06 + } else { + let vegetation = tile.canopy_cover * 0.7 + tile.undergrowth * 0.3; + 0.35 + vegetation * (0.12 - 0.35) + }; + + // Solar by row matching solarByRow() in runner.ts + let row_frac = tile.row as f32 / height; + let solar = 1.0 - (row_frac - 0.5).abs() * 0.7; + + albedo_sum += albedo; + solar_sum += solar * (1.0 - albedo); + aerosol_sum += tile.sulfate_aerosol; + wind_speed_sum += tile.wind_speed; + surface_water_sum += tile.surface_water; + + if is_water { + water_count += 1; + water_quality_sum += tile.quality as f32; + water_reef_sum += tile.reef_health; + water_fish_sum += tile.fish_stock; + } else { + land_count += 1; + temp_sum += tile.temperature; + moist_sum += tile.moisture; + land_canopy_sum += tile.canopy_cover; + land_undergrowth_sum += tile.undergrowth; + land_fungi_sum += tile.fungi_network; + land_habitat_sum += tile.habitat_suitability; + land_quality_sum += tile.quality as f32; + let veg = tile.canopy_cover * 0.6 + tile.undergrowth * 0.4; + et_sum += veg * 0.15; + } + } + + let nf = n.max(1) as f32; + let lf = land_count.max(1) as f32; + let wf = water_count.max(1) as f32; + let avg_temp = temp_sum / lf; + let avg_moist = moist_sum / lf; + + // Build terrain/substrate/elevation JSON fragments + let terrain_json = terrain_counts.iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect::>().join(","); + let substrate_json = substrate_counts.iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect::>().join(","); + let elevation_json = elevation_counts.iter() + .map(|(k, v)| format!("\"{}\":{}", k, v)) + .collect::>().join(","); + + format!( + r#"{{"avg_temp":{avg_temp},"avg_moisture":{avg_moist},"total_ley_strength":0,"dominant_ley_school":"","ley_school_strengths":{{"death":0,"life":0,"nature":0,"aether":0,"chaos":0}},"ley_land_coverage":{{"death":0,"life":0,"nature":0,"aether":0,"chaos":0}},"ocean_pct":{ocean_pct},"ocean_dead_pct":{ocean_dead_pct},"sea_level":{sea_level},"avg_albedo":{avg_albedo},"avg_solar":{avg_solar},"avg_land_canopy":{avg_land_canopy},"avg_land_undergrowth":{avg_land_undergrowth},"avg_land_fungi":{avg_land_fungi},"avg_land_habitat":{avg_land_habitat},"avg_water_reef":{avg_water_reef},"avg_water_fish":{avg_water_fish},"avg_land_quality":{avg_land_quality},"avg_water_quality":{avg_water_quality},"avg_aerosol":{avg_aerosol},"avg_wind_speed":{avg_wind_speed},"avg_evapotranspiration":{avg_et},"net_energy":{net_energy},"net_hydro":{net_hydro},"avg_surface_water":{avg_surface_water},"total_ocean_water":{total_ocean_water},"terrain_counts":{{{terrain_json}}},"substrate_counts":{{{substrate_json}}},"elevation_counts":{{{elevation_json}}},"o2_fraction":{o2_fraction},"co2_ppm":{co2_ppm},"ch4_ppb":{ch4_ppb},"ecological_collapse":{ecological_collapse},"ocean_toxic":{ocean_toxic},"ocean_anoxic":{ocean_anoxic},"dead_ocean":{dead_ocean},"canfield_ocean":{canfield_ocean},"ocean_toxicity":{ocean_toxicity},"ocean_o2_contribution":{ocean_o2_contribution},"global_fish_stock":{global_fish_stock},"trophic_cascade_active":{trophic_cascade_active}}}"#, + avg_temp = avg_temp, + avg_moist = avg_moist, + ocean_pct = if n > 0 { (n - land_count) as f32 / n as f32 } else { 0.0 }, + ocean_dead_pct = grid.ocean_dead_fraction, + sea_level = grid.sea_level, + avg_albedo = albedo_sum / nf, + avg_solar = solar_sum / nf, + avg_land_canopy = land_canopy_sum / lf, + avg_land_undergrowth = land_undergrowth_sum / lf, + avg_land_fungi = land_fungi_sum / lf, + avg_land_habitat = land_habitat_sum / lf, + avg_water_reef = water_reef_sum / wf, + avg_water_fish = water_fish_sum / wf, + avg_land_quality = land_quality_sum / lf, + avg_water_quality = water_quality_sum / wf, + avg_aerosol = aerosol_sum / nf, + avg_wind_speed = wind_speed_sum / nf, + avg_et = et_sum / lf, + net_energy = avg_temp - prev_avg_temp, + net_hydro = avg_moist - prev_avg_moisture, + avg_surface_water = surface_water_sum / nf, + total_ocean_water = grid.total_ocean_water, + terrain_json = terrain_json, + substrate_json = substrate_json, + elevation_json = elevation_json, + o2_fraction = grid.o2_fraction, + co2_ppm = grid.co2_ppm, + ch4_ppb = grid.ch4_ppb, + ecological_collapse = grid.ecological_collapse, + ocean_toxic = grid.ocean_toxic, + ocean_anoxic = grid.ocean_anoxic, + dead_ocean = grid.dead_ocean, + canfield_ocean = grid.canfield_ocean, + ocean_toxicity = grid.ocean_toxicity, + ocean_o2_contribution = grid.ocean_o2_contribution, + global_fish_stock = grid.global_fish_stock, + trophic_cascade_active = grid.trophic_cascade_active, + ) + } +} + +/// WASM-exposed climate physics engine. +#[wasm_bindgen] +pub struct WasmClimatePhysics { + inner: ClimatePhysics, +} + +#[wasm_bindgen] +impl WasmClimatePhysics { + #[wasm_bindgen(constructor)] + pub fn new(params_json: &str, terrain_json: &str, spec_json: &str) -> Self { + Self { + inner: ClimatePhysics::new(params_json, terrain_json, spec_json), + } + } + + /// Run one turn of climate simulation. + #[wasm_bindgen(js_name = "processStep")] + pub fn process_step(&mut self, grid: &mut WasmGrid, turn: u32, seed: u32) { + self.inner.process_step(&mut grid.inner, turn, seed as u64); + } + + /// Run atmospheric chemistry using the spec stored at construction time. + /// Avoids reparsing spec_json on every turn — use this instead of the standalone + /// stepAtmosphericChemistry() WASM function for the hot simulation loop. + #[wasm_bindgen(js_name = "stepAtmosphericChemistry")] + pub fn step_atmospheric_chemistry(&self, grid: &mut WasmGrid) { + self.inner.step_atmospheric_chemistry(&mut grid.inner); + } + + /// Write per-tile data into Float32Array slices for GPU rendering. + /// Layout matches encodeSnapshot() in runner.ts exactly. + #[wasm_bindgen(js_name = "writeFrameBuffers")] + pub fn write_frame_buffers(&self, grid: &WasmGrid, tex_a: &mut [f32], tex_b: &mut [f32], tex_c: &mut [f32]) { + self.inner.write_frame_buffers(&grid.inner, tex_a, tex_b, tex_c); + } +} + +/// WASM-exposed ecology physics engine. +#[wasm_bindgen] +pub struct WasmEcologyPhysics { + inner: EcologyPhysics, +} + +#[wasm_bindgen] +impl WasmEcologyPhysics { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + inner: EcologyPhysics::new(), + } + } + + /// Run one tick of ecology simulation. + #[wasm_bindgen(js_name = "processStep")] + pub fn process_step(&mut self, grid: &mut WasmGrid) { + self.inner.process_step(&mut grid.inner); + } +} + +/// WASM-exposed atmospheric chemistry step. +#[wasm_bindgen(js_name = "stepAtmosphericChemistry")] +pub fn wasm_step_atmospheric_chemistry(grid: &mut WasmGrid, spec_json: &str) { + let spec: serde_json::Value = serde_json::from_str(spec_json).unwrap_or_default(); + step_atmospheric_chemistry(&mut grid.inner, &spec); +} + +/// WASM-exposed map generator. +#[wasm_bindgen] +pub struct WasmMapGenerator { + inner: MapGenerator, +} + +#[wasm_bindgen] +impl WasmMapGenerator { + #[wasm_bindgen(constructor)] + pub fn new(params_json: &str) -> Self { + Self { + inner: MapGenerator::new(params_json), + } + } + + /// Generate a map and return a WasmGrid. + pub fn generate(&self, seed: u32, map_size: &str) -> WasmGrid { + WasmGrid { + inner: self.inner.generate(seed as u64, map_size), + } + } +}