feat(api-wasm): ✨ Introduce WebAssembly API bindings for simulator execution
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
171d69bdb6
commit
c3936ac2fa
2 changed files with 319 additions and 0 deletions
18
src/simulator/api-wasm/Cargo.toml
Normal file
18
src/simulator/api-wasm/Cargo.toml
Normal file
|
|
@ -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
|
||||
301
src/simulator/api-wasm/src/lib.rs
Normal file
301
src/simulator/api-wasm/src/lib.rs
Normal file
|
|
@ -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<JsValue, JsError> {
|
||||
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<WasmGrid, JsError> {
|
||||
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::<Vec<_>>().join(",");
|
||||
let substrate_json = substrate_counts.iter()
|
||||
.map(|(k, v)| format!("\"{}\":{}", k, v))
|
||||
.collect::<Vec<_>>().join(",");
|
||||
let elevation_json = elevation_counts.iter()
|
||||
.map(|(k, v)| format!("\"{}\":{}", k, v))
|
||||
.collect::<Vec<_>>().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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue