summaryrefslogtreecommitdiff
path: root/src/space/survey.rs
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-11 15:24:37 -0800
committerskylar <me@casper>2026-11-11 15:24:37 -0800
commit9ccf951473e21a8b90c25161156fa09a9b227a7b (patch)
treee7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/space/survey.rs
parentd36c54d996f3c14ae811312dbbe621e12a39edde (diff)
Orrery 0.1.0: the galaxy, flight, cities, inventory and saves
Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src/space/survey.rs')
-rw-r--r--src/space/survey.rs365
1 files changed, 365 insertions, 0 deletions
diff --git a/src/space/survey.rs b/src/space/survey.rs
new file mode 100644
index 0000000..e3c91fe
--- /dev/null
+++ b/src/space/survey.rs
@@ -0,0 +1,365 @@
+//! 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<Temperature>,
+ /// How long a day lasts, or how fast a station spins.
+ pub day: Option<String>,
+ /// 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<f64>,
+ /// 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<u64>,
+}
+
+/// 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<u64> {
+ 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");
+ }
+}