From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: 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 --- src/space/survey.rs | 365 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/space/survey.rs (limited to 'src/space/survey.rs') 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, + /// 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"); + } +} -- cgit v1.2.3