//! What a ship's survey says about a place: how hot it is and how that //! swings over its year, how long its days are, how hard it pulls, what its //! air is like, what hangs in its sky, and how many people live there. //! None of it changes how the game plays. It's there so each place feels //! like somewhere. //! //! Temperatures are worked out, not made up: the starlight a body gets at //! its distance from the star (which changes along an eccentric orbit), //! less what it reflects, plus what its air traps or its insides give off. //! Moons share their planet's distance. Everything else is drawn from a //! hash of the place, so it's the same every visit and system generation //! (and every universe already made) is left alone. use std::f64::consts::TAU; use super::orbit::{EARTH_MASS, Orbit}; use super::system::{Climate, Landing, PlaceKind, StarKind, System, SystemKind}; use crate::names::Culture; use crate::scenes::sky::hash01; use crate::seed; /// How warm a bare, black, quickly turning rock gets 1 AU from a star of /// one sun, in kelvin. const WARMTH_AT_1AU: f64 = 278.6; /// Water freezes at this many kelvin. pub const FREEZING: f64 = 273.15; /// How wide the Sun looks from 1 AU, in degrees. const SUN_AT_1AU: f64 = 0.533; const EARTH_RADIUS_KM: f64 = 6371.0; const AU_KM: f64 = 149.6e6; /// Rocks in the belt: dark, and no air to keep them warm. const BELT_CLIMATE: Climate = Climate { albedo: 0.1, warmth: 0.0, air: &[], breathable: false }; pub struct Survey { /// At the surface, or a giant's cloud tops. None aboard a station, /// where it's whatever they set it to. pub temperature: Option, /// How long a day lasts, or how fast a station spins. pub day: Option, /// What its tilt does to its year, if it has days to speak of. pub seasons: Option<&'static str>, /// In g: at the surface, at a giant's cloud tops, or from a station's spin. pub gravity: Option, /// What the air is like, and whether you can breathe it. None if /// there isn't any. pub air: Option<(&'static str, bool)>, /// What you'd see overhead. pub sky: String, /// How many live there, if anyone does. pub population: Option, } /// In kelvin. #[derive(Clone, Copy, Debug)] pub struct Temperature { pub now: f64, /// At the far end of its orbit. pub coldest: f64, /// At the near end. pub hottest: f64, } impl Temperature { fn along(orbit: &Orbit, day: f64, star: &StarKind, climate: &Climate) -> Temperature { let at = |distance: f64| { WARMTH_AT_1AU * (star.luminosity * (1.0 - climate.albedo)).powf(0.25) / distance.sqrt() + climate.warmth }; Temperature { now: at(orbit.position(day).length()), coldest: at(orbit.apoapsis_distance()), hottest: at(orbit.periapsis_distance()), } } /// One word for how it feels, going by the temperature now. pub fn word(self) -> &'static str { match self.now - FREEZING { c if c < -150.0 => "deep-frozen", c if c < -60.0 => "frozen", c if c < -20.0 => "bitter", c if c < 5.0 => "cold", c if c < 15.0 => "cool", c if c < 28.0 => "temperate", c if c < 45.0 => "hot", c if c < 120.0 => "scorching", c if c < 400.0 => "searing", _ => "molten", } } } /// Kelvin to whole degrees Celsius, for showing. pub fn celsius(kelvin: f64) -> i64 { (kelvin - FREEZING).round() as i64 } pub fn survey(s: &System, place: usize, day: f64) -> Survey { let key = seed::derive(s.seed, "survey", place as u64); let h = |i: i64| hash01(key, i, 0); let star = s.star; let landing = s.landing(place); match s.places[place].kind { PlaceKind::Planet(p) => { let planet = &s.planets[p]; let kind = planet.kind; let temperature = Temperature::along(&planet.orbit, day, star, &kind.climate); // Close to a small star, tides stop a world turning. let locked = !kind.giant && planet.orbit.a < 0.4 * star.mass; let (day_length, seasons) = if locked { ("locked: one face in endless day".to_string(), None) } else { let hours = if kind.giant { lerp(9.0, 18.0, h(1)) } else if h(2) < 0.08 { 24.0 * log_lerp(5.0, 120.0, h(1)) } else { log_lerp(10.0, 80.0, h(1)) }; (duration(hours), Some(seasons(h(3), h(4)))) }; let earths = planet.mass / EARTH_MASS; let radius = radius(earths, kind.giant); let moons = match planet.moons.len() { 0 => String::new(), 1 => " and a moon".to_string(), n => format!(" and {n} moons"), }; Survey { temperature: Some(temperature), day: Some(day_length), seasons, gravity: Some(earths / (radius * radius)), air: air(&kind.climate, temperature, h(5)), sky: format!("{}{moons}", sun(star, planet.orbit.position(day).length())), population: population(landing, s.kind, p == s.home, false, h(6)), } } PlaceKind::Moon(p, m) => { let planet = &s.planets[p]; let moon = &planet.moons[m]; let temperature = Temperature::along(&planet.orbit, day, star, &moon.kind.climate); let earths = log_lerp(0.004, 0.12, h(1)); // How wide its planet looks, from down on the surface. let planet_km = radius(planet.mass / EARTH_MASS, planet.kind.giant) * EARTH_RADIUS_KM; let across = 2.0 * (planet_km / (moon.orbit.a * AU_KM)).atan().to_degrees(); let size = if across < 1.0 { format!("{across:.1}") } else { format!("{across:.0}") }; let looms = if across >= 3.0 { "looms" } else { "hangs" }; Survey { temperature: Some(temperature), day: Some(format!( "{}, always facing {}", duration(24.0 * moon.orbit.period), planet.name )), seasons: None, gravity: Some(earths.powf(0.46)), air: air(&moon.kind.climate, temperature, h(5)), sky: format!( "{} {looms} {size}° wide; {}", planet.name, sun(star, planet.orbit.position(day).length()) ), population: population(landing, s.kind, false, true, h(6)), } } PlaceKind::Station(k) => { let station = &s.stations[k]; let planet = &s.planets[station.planet]; // Spun for gravity: the bigger the wheel, the slower it turns. let g = lerp(0.35, 1.0, h(1)); let wheel = lerp(150.0, 900.0, h(2)); let seconds = TAU * (wheel / (g * 9.81)).sqrt(); Survey { temperature: None, day: Some(format!("turns once every {seconds:.0} seconds")), seasons: None, gravity: Some(g), air: Some((station_air(station.culture), true)), sky: format!( "{}, and {} a bright star", sun(star, planet.orbit.position(day).length()), planet.name ), population: population(landing, s.kind, false, false, h(6)), } } PlaceKind::Belt => { let region = &s.belt.region; Survey { temperature: Some(Temperature::along(region, day, star, &BELT_CLIMATE)), day: None, seasons: None, gravity: None, air: None, sky: format!("{}, and rocks tumbling all round", sun(star, region.a)), population: None, } } } } /// In Earth radii, for a body of `earths` Earth masses: rocky worlds grow /// slowly with mass, and giants stop growing at about Jupiter's size. fn radius(earths: f64, giant: bool) -> f64 { if giant { earths.powf(0.48).min(11.3) } else { earths.powf(0.27) } } fn air(climate: &Climate, t: Temperature, h: f64) -> Option<(&'static str, bool)> { if climate.air.is_empty() { return None; } let what = climate.air[(h * climate.air.len() as f64) as usize % climate.air.len()]; let bearable = (FREEZING - 40.0..=FREEZING + 55.0).contains(&t.now); Some((what, climate.breathable && bearable)) } fn station_air(culture: Culture) -> &'static str { match culture { Culture::Qaari => "warm and dry, scented with spice-tea", Culture::Cant => "recycled; hot solder and incense", Culture::Houses => "perfumed, and filtered twice over", Culture::Drift => "stale, and patched like the hulls", } } /// The star as it looks from `distance` AU. fn sun(star: &StarKind, distance: f64) -> String { let colour = star.name.trim_end_matches(" dwarf").trim_end_matches(" star"); // Near enough for a star in the prime of its life. let radius = star.mass.powf(0.8); let degrees = SUN_AT_1AU * radius / distance; let size = match degrees { d if d < 0.12 => return format!("a {colour} sun, shrunk to a point"), d if d < 0.35 => "small ", d if d < 0.9 => "", d if d < 2.5 => "broad ", d if d < 6.0 => "vast ", _ => "sky-filling ", }; format!("a {size}{colour} sun") } /// How much its tilt changes its year. Most worlds lean a little; a few /// roll round their star on their side. fn seasons(h: f64, h2: f64) -> &'static str { let tilt = if h < 0.05 { 80.0 + 20.0 * h2 } else { 40.0 * h2 * h2 }; match tilt { t if t < 4.0 => "no seasons", t if t < 15.0 => "mild seasons", t if t < 30.0 => "strong seasons", t if t < 60.0 => "harsh seasons", _ => "spins on its side", } } /// How many people live somewhere: billions on a seat's homeworld, far /// fewer out on the frontier or among the wrecks. fn population(landing: Landing, kind: SystemKind, main: bool, moon: bool, h: f64) -> Option { let (low, high) = match landing { Landing::Capital(_) if main => match kind { SystemKind::Seat => (6e8, 4e9), SystemKind::Settled => (4e7, 6e8), SystemKind::Rubble => (3e6, 6e7), _ => (2e5, 8e6), }, Landing::Capital(_) if moon => (2e5, 2e7), Landing::Capital(_) => (5e6, 3e8), Landing::Aloft(_) => (1e6, 4e7), Landing::Market(_) => (3e3, 8e4), Landing::Wilds | Landing::Nowhere => return None, }; // Away from the settled heart of things, everywhere is emptier. let thin = matches!(kind, SystemKind::Frontier | SystemKind::Graveyard) && !main; let (low, high) = if thin { (low * 0.1, high * 0.1) } else { (low, high) }; // To two figures: nobody counts heads more closely than that. let n = log_lerp(low, high, h); let unit = 10f64.powi(n.log10().floor() as i32 - 1); Some(((n / unit).round() * unit) as u64) } /// "26 hours", "3.5 days", "41 days". pub fn duration(hours: f64) -> String { let days = hours / 24.0; if hours < 72.0 { format!("{hours:.0} hours") } else if days < 10.0 { format!("{days:.1} days") } else { format!("{days:.0} days") } } fn lerp(low: f64, high: f64, k: f64) -> f64 { low + (high - low) * k } /// From `low` to `high`, evenly in orders of magnitude. fn log_lerp(low: f64, high: f64, k: f64) -> f64 { (low.ln() + (high.ln() - low.ln()) * k).exp() } #[cfg(test)] mod tests { use super::*; use crate::space::system::generate; #[test] fn surveys_are_the_same_every_time() { let s = generate(4); for place in 0..s.places.len() { let (a, b) = (survey(&s, place, 30.0), survey(&s, place, 30.0)); assert_eq!((a.day, a.sky, a.population), (b.day, b.sky, b.population)); } } #[test] fn temperatures_swing_between_the_ends_of_the_orbit() { for seed in 0..60 { let s = generate(seed); for place in 0..s.places.len() { for day in [0.0, 100.0, 1234.5] { let Some(t) = survey(&s, place, day).temperature else { continue }; assert!(t.coldest <= t.now + 1e-9 && t.now <= t.hottest + 1e-9); assert!(t.coldest > 0.0, "seed {seed}: below absolute zero"); } } } } #[test] fn a_wet_world_in_the_middle_of_the_habitable_zone_is_mild() { let star = &crate::space::system::STARS[0]; let hz = star.luminosity.sqrt(); let orbit = Orbit::new(hz, 0.0, 0.0, 0.0, star.mass); let ocean = Climate { albedo: 0.3, warmth: 33.0, air: &["wet"], breathable: true }; let t = Temperature::along(&orbit, 0.0, star, &ocean); assert_eq!(celsius(t.now), 15, "about Earth's average"); assert_eq!(t.word(), "cool"); assert_eq!(air(&ocean, t, 0.0), Some(("wet", true))); } #[test] fn only_settled_places_have_people() { for seed in 0..40 { let s = generate(seed); for place in 0..s.places.len() { let people = survey(&s, place, 0.0).population; assert_eq!( people.is_some(), !matches!(s.landing(place), Landing::Wilds | Landing::Nowhere) ); } let home = survey(&s, s.home_place(), 0.0).population.unwrap(); assert!(home >= 600_000_000, "seed {seed}: a homeworld of {home}"); } } #[test] fn durations_read_naturally() { assert_eq!(duration(26.2), "26 hours"); assert_eq!(duration(84.0), "3.5 days"); assert_eq!(duration(24.0 * 41.2), "41 days"); } }