//! Star systems: a star, planets on Kepler orbits with moons of their own, //! an asteroid belt, stations at Lagrange points, and wrecks that come and //! go with the calendar. //! //! Planets are laid out outward from the star, each orbit 1.5-2 times the //! last (roughly how real systems space out). What a planet is depends on //! where it sits: scorched rock close in, temperate worlds around the //! habitable zone, ice beyond, and giants past the frost line. The belt //! takes the first wide gap past the frost line. //! //! Everything you can travel to is a `Place`, listed outward from the star. //! //! Systems come in kinds (see `SystemKind`): a people's seat, ordinary //! settled systems, thin frontiers, desolate ones no one lives in, rubble //! fields with a great belt, and graveyards of old wars. The galaxy decides //! which kind each system is, and who holds it, and hands that over as a //! `Profile`. The system a universe first started you in is made without //! one, exactly as it always was, so old universes and saves keep it. use std::cell::RefCell; use std::collections::{BTreeMap, HashSet}; use std::f64::consts::{PI, TAU}; use std::ops::RangeInclusive; use rand::RngExt; use rand::seq::IndexedRandom; use super::Vec2; use super::orbit::{EARTH_MASS, Orbit}; use crate::dungeon::crawl; use crate::names::Culture; use crate::seed::{self, Rng}; pub type Rgb = (u8, u8, u8); pub struct StarKind { pub name: &'static str, pub rgb: Rgb, /// In suns. pub mass: f64, /// In suns; sets where the habitable zone is. pub luminosity: f64, weight: u32, /// How likely it is in a system no one lives in. dead_weight: u32, } #[rustfmt::skip] pub const STARS: &[StarKind] = &[ StarKind { name: "yellow dwarf", rgb: (255, 226, 130), mass: 1.0, luminosity: 1.0, weight: 4, dead_weight: 1 }, StarKind { name: "orange dwarf", rgb: (255, 168, 90), mass: 0.75, luminosity: 0.35, weight: 3, dead_weight: 2 }, StarKind { name: "red dwarf", rgb: (255, 110, 80), mass: 0.4, luminosity: 0.04, weight: 2, dead_weight: 5 }, StarKind { name: "blue-white star", rgb: (175, 205, 255), mass: 1.9, luminosity: 9.0, weight: 1, dead_weight: 2 }, ]; /// A star for a system of this kind: dim red dwarfs, mostly, where no one /// lives. pub fn random_star(kind: SystemKind, rng: &mut Rng) -> &'static StarKind { if kind == SystemKind::Desolate { STARS.choose_weighted(rng, |s| s.dead_weight).unwrap() } else { STARS.choose_weighted(rng, |s| s.weight).unwrap() } } /// What sort of system it is, which shapes everything in it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum SystemKind { /// The seat of a people: their homeworld and its great capital. Seat, /// An ordinary system: a capital, settled worlds, a station or two. Settled, /// Thinly settled: a small capital or an outpost station. Frontier, /// No one lives here at all, and nothing is for sale. Desolate, /// Few planets, and a wide belt thick with ore, worked by mining stations. Rubble, /// An old battlefield: wrecks everywhere, few people. Graveyard, } impl SystemKind { pub const ALL: [SystemKind; 6] = [ SystemKind::Seat, SystemKind::Settled, SystemKind::Frontier, SystemKind::Desolate, SystemKind::Rubble, SystemKind::Graveyard, ]; /// "a frontier system", for "a frontier system of the Qaari". pub fn name(self) -> &'static str { match self { SystemKind::Seat => "the seat", SystemKind::Settled => "a settled system", SystemKind::Frontier => "a frontier system", SystemKind::Desolate => "a desolate system", SystemKind::Rubble => "a rubble field", SystemKind::Graveyard => "a graveyard of ships", } } /// What you'll find there, in a sentence. pub fn blurb(self) -> &'static str { match self { SystemKind::Seat => "Their homeworld and its great capital, busy stations, fuel.", SystemKind::Settled => "Settled worlds round a capital, a station or two, fuel.", SystemKind::Frontier => "Thinly settled: a small capital or an outpost, and fuel.", SystemKind::Desolate => "No one lives here. Nothing is for sale, fuel least of all.", SystemKind::Rubble => "A wide belt thick with ore, worked by mining stations.", SystemKind::Graveyard => "An old battlefield. Wrecks drift everywhere; few people.", } } /// Whether anyone in a system of this kind sells fuel. Only desolate /// systems have no one to. pub fn sells_fuel(self) -> bool { self != SystemKind::Desolate } } /// What the galaxy decides about a system before it's made. pub struct Profile { pub name: String, /// Who holds it. pub culture: Culture, pub star: &'static StarKind, pub kind: SystemKind, /// The people over the border, for a system on one: they settle here /// too, and may keep a station. pub neighbour: Option, } /// How a kind of system is laid out and settled. struct Rules { planets: RangeInclusive, /// How much wider than it starts the belt is, outer edge over inner. belt_width: (f64, f64), /// Scales every world's chance of being settled. settle: f64, /// Of settled worlds, the share the system's own people hold. own_share: f64, /// Who holds the rest; anyone at all, if no one in particular. minority: Option, /// The chance the main world has a capital. capital: f64, /// No life: oceans and jungles give way to rock and ice. dead: bool, stations: Stations, /// How many wrecks are calling at once, on average. wrecks: f64, /// Rocks in the belt, and the share with ore in them, against an /// ordinary belt's. density: f64, richness: f64, } #[derive(Clone, Copy, PartialEq)] enum Stations { /// One at the heaviest planet, and often one at the main world. Usual, /// One at the heaviest planet and one at the main world, or another. Two, /// Exactly one. One, /// One if there's no capital to trade at; otherwise, maybe. Outpost, None, } impl Rules { /// The rules a system was always made by, before there were kinds. const ORIGINAL: Rules = Rules { planets: 4..=7, belt_width: (1.4, 1.7), settle: 1.0, own_share: 0.7, minority: None, capital: 1.0, dead: false, stations: Stations::Usual, wrecks: 2.5, density: 1.0, richness: 0.6, }; fn for_profile(p: &Profile) -> Rules { // On a border, the neighbours settle nearly as much as the owners. let (own_share, minority) = match p.neighbour { Some(n) => (0.6, Some(n)), None => (0.92, None), }; let base = Rules { own_share, minority, ..Rules::ORIGINAL }; match p.kind { SystemKind::Seat => Rules { stations: Stations::Two, ..base }, SystemKind::Settled => base, SystemKind::Frontier => Rules { planets: 3..=6, settle: 0.35, capital: 0.55, stations: Stations::Outpost, wrecks: 2.0, ..base }, SystemKind::Desolate => Rules { planets: 3..=6, settle: 0.0, capital: 0.0, dead: true, stations: Stations::None, wrecks: 2.0, ..base }, SystemKind::Rubble => Rules { planets: 2..=4, belt_width: (2.2, 2.8), settle: 0.6, capital: 0.5, stations: Stations::Two, density: 1.6, richness: 0.85, ..base }, SystemKind::Graveyard => { Rules { settle: 0.3, capital: 0.35, stations: Stations::One, wrecks: 6.5, ..base } } } } } pub struct BodyKind { pub name: &'static str, pub rgb: Rgb, /// Drawn with cloud bands. pub banded: bool, /// Radius when you're in orbit around it, in local map units. Not to /// scale; giants are just bigger. pub size: f64, pub giant: bool, /// The chance someone lives there. settled: f64, pub climate: Climate, pub terrain: Terrain, flavour: &'static [&'static str], } /// What a world's ground is, for a city built on it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Terrain { Desert, Ice, Ocean, Jungle, Barren, Forge, /// A giant's: no ground at all. No one builds a city on one. Clouds, } /// How a kind of world holds heat, and what its air is like. Only for /// show: see `space::survey`. pub struct Climate { /// How much starlight it reflects, from 0 to 1. pub albedo: f64, /// How much warmer it is than bare rock would be at the same distance, /// in kelvin: heat its air traps, or its insides give off. pub warmth: f64, /// A few ways of putting what the air is like. Empty if there's none. pub air: &'static [&'static str], /// Whether people could breathe it, if it weren't too hot or cold. pub breathable: bool, } macro_rules! kind { ($name:expr, $terrain:expr, $rgb:expr, $size:expr, $settled:expr, $climate:expr, $($flavour:expr),+) => { BodyKind { name: $name, rgb: $rgb, banded: false, size: $size, giant: false, settled: $settled, climate: $climate, terrain: $terrain, flavour: &[$($flavour),+] } }; } macro_rules! climate { ($albedo:expr, $warmth:expr, $breathable:expr, [$($air:expr),*]) => { Climate { albedo: $albedo, warmth: $warmth, breathable: $breathable, air: &[$($air),*] } }; } const DESERT: BodyKind = kind!( "desert world", Terrain::Desert, (214, 172, 104), 10.0, 0.7, climate!(0.25, 35.0, true, ["thin, dry and dusty", "dry; it smells of hot stone"]), "Wind-carved dunes run unbroken from pole to pole.", "Glass storms scour the plains at dusk.", "Under the sand lie the bones of something enormous." ); const ICE: BodyKind = kind!( "ice world", Terrain::Ice, (190, 222, 240), 9.0, 0.3, climate!(0.6, 8.0, false, ["thin and bitter", "a whisper of nitrogen"]), "Blue glaciers grind slowly over a buried sea.", "Its auroras whistle and sing on the comm bands." ); const OCEAN: BodyKind = kind!( "ocean world", Terrain::Ocean, (70, 124, 205), 11.0, 0.6, climate!(0.3, 33.0, true, ["thick, humid and salty", "warm, wet and briny"]), "One endless ocean, broken by a scatter of black islands.", "Kelp forests a hundred metres tall sway beneath the waves." ); const JUNGLE: BodyKind = kind!( "jungle world", Terrain::Jungle, (84, 164, 84), 11.0, 0.5, climate!(0.25, 40.0, true, ["dense, wet, spore-laden", "heavy and green-smelling"]), "Canopy covers everything; nothing on the ground has seen the sun.", "The air is thick with spores and slow-drifting seed-kites." ); const BARREN: BodyKind = kind!( "barren rock", Terrain::Barren, (146, 136, 124), 7.0, 0.15, climate!(0.12, 0.0, false, []), "Cratered grey rock, airless and silent.", "Ancient mine shafts riddle the crust." ); const FORGE: BodyKind = kind!( "forge world", Terrain::Forge, (200, 86, 60), 9.0, 0.4, climate!(0.15, 220.0, false, ["sulphurous and choking", "thin, hot and ashy"]), "Rivers of lava light up the night side.", "The crust is thin and restless, and the ground shakes daily." ); const GAS_GIANT: BodyKind = BodyKind { banded: true, giant: true, ..kind!( "gas giant", Terrain::Clouds, (206, 154, 112), 20.0, 0.25, climate!(0.34, 45.0, false, ["hydrogen and helium", "hydrogen, streaked with ammonia"]), "Storms the size of worlds turn in its banded clouds.", "Cloud-skimmers dive into its upper layers for fuel." ) }; const ICE_GIANT: BodyKind = BodyKind { banded: true, giant: true, ..kind!( "ice giant", Terrain::Clouds, (120, 200, 210), 16.0, 0.1, climate!(0.3, 15.0, false, ["hydrogen and methane haze", "cold hydrogen, faintly blue"]), "A pale blue-green ball of methane haze, cold and still.", "Diamond hail falls forever through its depths." ) }; /// What a planet `a` AU out is likely to be, given where the habitable zone /// (`hz`) and the frost line are. fn planet_kinds(a: f64, hz: f64) -> &'static [&'static BodyKind] { if a < 0.75 * hz { &[&FORGE, &BARREN, &DESERT] } else if a < 1.7 * hz { &[&OCEAN, &JUNGLE, &DESERT, &DESERT] } else if a < FROST_LINE * hz { &[&ICE, &BARREN, &DESERT] } else { &[&GAS_GIANT, &GAS_GIANT, &ICE_GIANT] } } /// The same, in a dead system: no oceans or jungles, only rock and ice. fn dead_kinds(a: f64, hz: f64) -> &'static [&'static BodyKind] { if a < 0.75 * hz { &[&FORGE, &BARREN, &BARREN] } else if a < 1.7 * hz { &[&BARREN, &DESERT, &BARREN] } else if a < FROST_LINE * hz { &[&ICE, &BARREN] } else { &[&GAS_GIANT, &ICE_GIANT, &ICE_GIANT] } } /// Beyond this many habitable-zone distances out, water freezes and giants form. const FROST_LINE: f64 = 2.7; /// What a moon is likely to be, inside the frost line and beyond it. Forge /// moons are the ones a giant kneads hot with its tides. const MOON_KINDS: [&[&BodyKind]; 2] = [&[&BARREN, &BARREN, &DESERT, &FORGE], &[&ICE, &ICE, &BARREN, &FORGE]]; fn settlements(culture: Culture) -> &'static [&'static str] { match culture { Culture::Qaari => &[ "Qaari caravan-cities crawl across the surface on great tracked platforms.", "The Qaari keep a water-court here, where oaths are sworn over cisterns.", ], Culture::Cant => &[ "Foundry-cathedrals of the Cant hum day and night with litanies.", "The Cant strip old hulls here, and sing over the parts.", ], Culture::Houses => &[ "A High House palace sprawls across the one temperate valley.", "House banners hang from the spires of a lofty court-city.", ], Culture::Drift => &[ "A tangle of lashed-together hulls hangs in orbit: a Drift anchorage.", "The Drift trade here, from a market of docked ships.", ], } } const UNSETTLED: &[&str] = &[ "No one lives here. At night the only lights are the stars.", "A survey beacon still blinks on the surface, long abandoned.", ]; pub struct Planet { pub name: String, pub kind: &'static BodyKind, /// Who lives there, if anyone. pub people: Option, pub orbit: Orbit, /// In suns. pub mass: f64, pub moons: Vec, /// A few sentences for the landing screen. pub description: Vec, } pub struct Moon { pub name: String, pub kind: &'static BodyKind, pub people: Option, /// Around its planet. pub orbit: Orbit, pub description: Vec, } pub struct Belt { pub name: String, pub inner: f64, pub outer: f64, /// The stretch of belt you can travel to, which goes round with it. pub region: Orbit, /// How many rocks there are, against an ordinary belt. pub density: f64, /// The chance a rock has ore in it. pub richness: f64, } pub struct Station { pub name: String, pub culture: Culture, /// The planet whose Lagrange point it sits at. pub planet: usize, /// At L4, sixty degrees ahead of the planet, rather than L5, behind it. pub leading: bool, pub description: Vec, } /// A derelict with its distress beacon calling. Wrecks come and go: each /// drifts in on some day, calls for a month or so, and then its beacon fails /// and it's lost to the dark. #[derive(Clone, Debug, PartialEq)] pub struct Wreck { /// Board it and this seeds the derelict you find. pub seed: u64, pub name: String, /// "Cant hauler". pub description: String, /// Which place it drifts in. pub place: usize, /// The day its beacon started calling. pub arrived: f64, /// The day its beacon fails and it's gone. pub leaves: f64, } impl Wreck { pub fn calling(&self, day: f64) -> bool { self.arrived <= day && day < self.leaves } } /// Wrecks turn up in batches, one batch for every this many days. const WRECK_EPOCH: f64 = 10.0; /// How long a beacon calls, in days. const BEACON_LIFE: (f64, f64) = (20.0, 45.0); /// The most wrecks one batch can bring. const WRECK_TRIES: usize = 6; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PlaceKind { /// Orbit around a planet. Planet(usize), /// Orbit around a planet's moon. Moon(usize, usize), Belt, Station(usize), } /// What there is for you when you land or dock somewhere. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Landing { /// People live on a world with ground to land on: you set down outside /// their capital and walk in, to a full set of shops. Capital(Culture), /// A station: dock and sell ore, and that's all. Market(Culture), /// People live in a giant's clouds, but there's no ground to land on. Aloft(Culture), /// A world no one lives on: land and look around, with no one to trade with. Wilds, /// Nowhere to land at all: the belt, or an empty giant. Nowhere, } impl Landing { /// Whether anyone here buys ore. pub fn trades(self) -> bool { matches!(self, Landing::Capital(_) | Landing::Market(_)) } } pub struct Place { pub kind: PlaceKind, /// "Qeth Amarul", "the Zirim Belt". pub name: String, } pub struct System { pub seed: u64, pub name: String, /// Whose space this is; most settled worlds here are theirs. pub culture: Culture, pub kind: SystemKind, pub star: &'static StarKind, pub planets: Vec, /// The system's main world: in a seat, the homeworld of its people, /// where you start. In a system no one lives in, the world likeliest to /// have been settled. pub home: usize, pub belt: Belt, pub stations: Vec, /// Everywhere you can travel to, outward from the star. pub places: Vec, /// How many wrecks are calling at once, on average. wreck_rate: f64, /// On a battlefield, the place most wrecks turn up. battle: Option, /// Batches of wrecks already made, by epoch: naming one means /// generating its whole ship. batches: RefCell>>, } impl System { /// Where a place is on `day`, in AU from the star. pub fn position(&self, place: usize, day: f64) -> Vec2 { match self.places[place].kind { PlaceKind::Planet(p) => self.planets[p].orbit.position(day), PlaceKind::Moon(p, m) => { self.planets[p].orbit.position(day) + self.planets[p].moons[m].orbit.position(day) } PlaceKind::Belt => self.belt.region.position(day), PlaceKind::Station(s) => { let station = &self.stations[s]; let lead = if station.leading { PI / 3.0 } else { -PI / 3.0 }; self.planets[station.planet].orbit.position(day).rotated(lead) } } } /// What there is for you if you land or dock at a place. pub fn landing(&self, place: usize) -> Landing { let world = |kind: &BodyKind, people: Option| match (people, kind.giant) { (Some(c), true) => Landing::Aloft(c), (Some(c), false) => Landing::Capital(c), (None, true) => Landing::Nowhere, (None, false) => Landing::Wilds, }; match self.places[place].kind { PlaceKind::Planet(p) => world(self.planets[p].kind, self.planets[p].people), PlaceKind::Moon(p, m) => { let moon = &self.planets[p].moons[m]; world(moon.kind, moon.people) } PlaceKind::Station(s) => Landing::Market(self.stations[s].culture), PlaceKind::Belt => Landing::Nowhere, } } /// The place for orbit around the homeworld, where journeys start. pub fn home_place(&self) -> usize { self.places.iter().position(|p| p.kind == PlaceKind::Planet(self.home)).unwrap() } /// Whether the main world is the homeworld of a people, not just the /// biggest place in the system. pub fn is_homeworld(&self, planet: usize) -> bool { planet == self.home && self.kind == SystemKind::Seat } /// Where a jump brings you out: the outermost planet, or the belt if it /// lies further out. pub fn edge_place(&self) -> usize { self.places .iter() .rposition(|p| matches!(p.kind, PlaceKind::Planet(_) | PlaceKind::Belt)) .unwrap() } /// Whether you can buy fuel at a place: at a station's dock, or a /// city's shipyard. pub fn sells_fuel_at(&self, place: usize) -> bool { self.landing(place).trades() } /// Whether anywhere in the system sells fuel. pub fn sells_fuel(&self) -> bool { (0..self.places.len()).any(|i| self.sells_fuel_at(i)) } /// Where to go for fuel: the first station, or failing that the first /// capital. pub fn fuel_place(&self) -> Option { let station = |i: &usize| matches!(self.landing(*i), Landing::Market(_)); (0..self.places.len()) .find(station) .or_else(|| (0..self.places.len()).find(|&i| self.sells_fuel_at(i))) } /// How far out anything goes, in AU. pub fn extent(&self) -> f64 { let planets = self.planets.iter().map(|p| p.orbit.apoapsis_distance()); planets.fold(self.belt.outer, f64::max) } /// The wrecks whose beacons are calling on `day`, oldest first. pub fn wrecks_on(&self, day: f64) -> Vec { let first = ((day - BEACON_LIFE.1) / WRECK_EPOCH).floor() as i64; let last = (day / WRECK_EPOCH).floor() as i64; let mut wrecks: Vec = (first..=last).flat_map(|epoch| self.batch(epoch)).filter(|w| w.calling(day)).collect(); wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived)); wrecks } /// The wrecks calling on `day` that you haven't stripped yet. pub fn beacons(&self, day: f64, stripped: &HashSet) -> Vec { let mut wrecks = self.wrecks_on(day); wrecks.retain(|w| !stripped.contains(&w.seed)); wrecks } /// The wrecks you find at `place` on `day`, having set out for it on /// `departed`: those calling now, and those that were calling when you /// set out, even if their beacons have failed on the way, since you /// came by their last fix. Not those you've stripped. Oldest first. pub fn wrecks_at( &self, place: usize, day: f64, departed: Option, stripped: &HashSet, ) -> Vec { let mut wrecks = self.beacons(day, stripped); if let Some(departed) = departed.filter(|&d| d < day) { for w in self.beacons(departed, stripped) { if !wrecks.iter().any(|x| x.seed == w.seed) { wrecks.push(w); } } } wrecks.retain(|w| w.place == place); wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived)); wrecks } /// The wrecks that turned up in one epoch, whenever they leave. fn batch(&self, epoch: i64) -> Vec { if let Some(batch) = self.batches.borrow().get(&epoch) { return batch.clone(); } let batch_seed = seed::derive(self.seed, "wrecks", epoch as u64); let mut rng = seed::rng(batch_seed); // Enough turn up, for as long as they last, that `wreck_rate` are // calling at any one time. let expected = self.wreck_rate * WRECK_EPOCH / ((BEACON_LIFE.0 + BEACON_LIFE.1) / 2.0); let chance = (expected / WRECK_TRIES as f64).min(1.0); let count = (0..WRECK_TRIES).filter(|_| rng.random_bool(chance)).count(); let batch: Vec = (0..count) .map(|i| { let seed = seed::derive(batch_seed, "wreck", i as u64); let (name, description) = crawl::describe(seed); let place = match self.battle { Some(b) if rng.random_bool(0.6) => b, _ => rng.random_range(..self.places.len()), }; let arrived = (epoch as f64 + rng.random_range(0.0..1.0)) * WRECK_EPOCH; let leaves = arrived + rng.random_range(BEACON_LIFE.0..BEACON_LIFE.1); Wreck { seed, name, description, place, arrived, leaves } }) .collect(); self.batches.borrow_mut().insert(epoch, batch.clone()); batch } } /// The system a universe has always started you in, made from its seed /// alone: who holds it, its name and its star come from the seed too. pub fn generate(seed: u64) -> System { build(seed, None) } /// A system the galaxy has already decided things about. pub fn generate_as(seed: u64, profile: &Profile) -> System { build(seed, Some(profile)) } // Without a profile, this must draw from the RNG exactly as it always has, // or the system every old universe started in would change under its saves. fn build(seed: u64, profile: Option<&Profile>) -> System { let mut rng = seed::rng(seed); let (culture, system_name, star, kind, rules) = match profile { None => { let culture = random_culture(&mut rng); let name = culture.word(&mut rng); let star = STARS.choose_weighted(&mut rng, |s| s.weight).unwrap(); (culture, name, star, SystemKind::Seat, Rules::ORIGINAL) } Some(p) => (p.culture, p.name.clone(), p.star, p.kind, Rules::for_profile(p)), }; let hz = star.luminosity.sqrt(); // Orbits first, so the homeworld can be chosen before names are given. let count = rng.random_range(rules.planets.clone()); let mut orbits: Vec<(Orbit, &'static BodyKind, f64)> = Vec::new(); let mut belt_span = None; let mut a = hz * rng.random_range(0.3..0.55); let mut outermost = 0.0; for i in 0..count { if i > 0 { a *= rng.random_range(1.5..2.0); } if belt_span.is_none() && i > 0 && a > FROST_LINE * hz { let inner: f64 = outermost * 1.3; let outer = inner * rng.random_range(rules.belt_width.0..rules.belt_width.1); belt_span = Some((inner, outer)); a = a.max(outer * 1.45); } // Keep each orbit well clear of the last, however eccentric. let mut e = if rng.random_bool(0.15) { rng.random_range(0.12..0.22) } else { rng.random_range(0.0..0.08) }; while i > 0 && e > 1e-6 && a * (1.0 - e) < outermost * 1.15 { e *= 0.5; } let kinds = if rules.dead { dead_kinds(a, hz) } else { planet_kinds(a, hz) }; let kind = *kinds.choose(&mut rng).unwrap(); let mass = EARTH_MASS * if kind.giant { rng.random_range(15.0..320.0) } else { rng.random_range(0.05..2.5) }; let orbit = Orbit::new(a, e, rng.random_range(0.0..TAU), rng.random_range(0.0..TAU), star.mass); outermost = orbit.apoapsis_distance(); orbits.push((orbit, kind, mass)); } let (inner, outer) = belt_span.unwrap_or_else(|| { let inner = outermost * 1.3; let width = if rules.belt_width == Rules::ORIGINAL.belt_width { 1.5 } else { rng.random_range(rules.belt_width.0..rules.belt_width.1) }; (inner, inner * width) }); let region = Orbit::new((inner + outer) / 2.0, 0.0, 0.0, rng.random_range(0.0..TAU), star.mass); let word = culture.word(&mut rng); let belt = Belt { name: if kind == SystemKind::Rubble { format!("the {word} Shoals") } else { format!("the {word} Belt") }, inner, outer, region, density: rules.density, richness: rules.richness, }; // Home is the habitable-zone world nearest the middle of the zone. let home = (0..orbits.len()) .filter(|&i| !orbits[i].1.giant) .min_by(|&i, &j| { let off = |k: usize| (orbits[k].0.a / hz).ln().abs(); off(i).total_cmp(&off(j)) }) .unwrap_or(0); // And it's always somewhere people could live, unless nothing lives here. if !rules.dead { orbits[home].1 = *[&OCEAN, &JUNGLE, &DESERT].choose(&mut rng).unwrap(); } let capital = rules.capital >= 1.0 || rng.random_bool(rules.capital); let mut planets = Vec::new(); for (i, (orbit, kind, mass)) in orbits.into_iter().enumerate() { let people = if i == home { capital.then_some(culture) } else { settle(kind, culture, &rules, &mut rng) }; let name = match people { Some(c) => c.word(&mut rng), None => format!("{system_name} {}", roman(i + 1)), }; let moon_count = if kind.giant { rng.random_range(1..=4) } else { usize::from(rng.random_bool(0.4)) }; let mut moon_a = (if kind.giant { 0.002 } else { 0.0012 }) * rng.random_range(1.0..1.6); // Moons only stay put well inside the planet's Hill sphere, where // its pull beats the star's. let hill = orbit.periapsis_distance() * (mass / (3.0 * star.mass)).cbrt(); let frozen = orbit.a > FROST_LINE * hz; let mut moons = Vec::new(); for _ in 0..moon_count { if moon_a > 0.4 * hill { break; } let kind = *MOON_KINDS[usize::from(frozen)].choose(&mut rng).unwrap(); let people = settle(kind, culture, &rules, &mut rng).filter(|_| rng.random_bool(0.5)); let moon_name = match people { Some(c) => c.word(&mut rng), None => culture.word(&mut rng), }; let orbit = Orbit::new( moon_a, rng.random_range(0.0..0.05), rng.random_range(0.0..TAU), rng.random_range(0.0..TAU), mass, ); moons.push(Moon { name: moon_name, kind, people, orbit, description: describe(kind, people, &mut rng), }); moon_a *= rng.random_range(1.6..2.2); } planets.push(Planet { name, kind, people, orbit, mass, moons, description: describe(kind, people, &mut rng), }); } let stations = stations(&planets, home, culture, &rules, &mut rng); let places = list_places(&planets, &belt, &stations); // On a battlefield, most of the wrecks turn up in one place: where the // fighting was thickest, and still draws scavengers and the lost. let battle = (kind == SystemKind::Graveyard) .then(|| seed::rng(seed::derive(seed, "battle", 0)).random_range(..places.len())); System { seed, name: system_name, culture, kind, star, planets, home, belt, stations, places, wreck_rate: rules.wrecks, battle, batches: RefCell::new(BTreeMap::new()), } } fn settle(kind: &BodyKind, culture: Culture, rules: &Rules, rng: &mut Rng) -> Option { rng.random_bool((kind.settled * rules.settle).min(1.0)).then(|| { if rng.random_bool(rules.own_share) { culture } else { rules.minority.unwrap_or_else(|| random_culture(rng)) } }) } /// Stations at Lagrange points: usually one at the heaviest planet, and /// often one at the homeworld's too. fn stations( planets: &[Planet], home: usize, culture: Culture, rules: &Rules, rng: &mut Rng, ) -> Vec { let heaviest = (0..planets.len()).max_by(|&i, &j| planets[i].mass.total_cmp(&planets[j].mass)).unwrap(); let capital = planets[home].people.is_some() && !planets[home].kind.giant; let mut anchors = vec![heaviest]; match rules.stations { Stations::Usual => { if heaviest != home && rng.random_bool(0.6) { anchors.push(home); } } Stations::Two => { let second = if heaviest != home { Some(home) } else { (0..planets.len()) .filter(|&p| p != heaviest) .max_by_key(|&p| planets[p].moons.len()) }; anchors.extend(second); } Stations::One => {} Stations::Outpost => { if capital && rng.random_bool(0.5) { anchors.clear(); } } Stations::None => anchors.clear(), } anchors .into_iter() .enumerate() .map(|(i, p)| { // Over a border, the neighbours may keep the second station. let who = match rules.minority { Some(n) if i > 0 && rng.random_bool(0.5) => n, _ => culture, }; station(p, &planets[p], who, rng) }) .collect() } fn describe(kind: &BodyKind, people: Option, rng: &mut Rng) -> Vec { let settlement = match people { Some(c) => settlements(c).choose(rng).unwrap(), None => UNSETTLED.choose(rng).unwrap(), }; vec![kind.flavour.choose(rng).unwrap().to_string(), settlement.to_string()] } fn station(planet: usize, at: &Planet, culture: Culture, rng: &mut Rng) -> Station { let leading = rng.random_bool(0.5); let word = culture.word(rng); let name = match culture { Culture::Qaari => format!("the {word} Caravanserai"), Culture::Cant => format!("Foundry {word}"), Culture::Houses => format!("the {word} Court"), Culture::Drift => format!("{word} Anchorage"), }; let (point, side) = if leading { ("L4", "ahead of") } else { ("L5", "behind") }; let mut description = vec![format!( "It turns slowly at {}'s {point} point, sixty degrees {side} the planet, \ where the pulls of {} and the star balance.", at.name, at.name )]; description.push( match culture { Culture::Qaari => { "Its docks smell of spice-tea and hot metal; caravans of ore haulers queue outside." } Culture::Cant => { "Its hull is studded with salvaged plating, every plate engraved with a prayer." } Culture::Houses => { "Liveried pilots wait in its gilded docks, and everyone is watching everyone." } Culture::Drift => { "It's less a station than a raft of docked ships, rearranged every season." } } .to_string(), ); Station { name, culture, planet, leading, description } } /// Every place, in order outward: each planet followed by its moons and any /// station at its Lagrange points, with the belt where it falls. fn list_places(planets: &[Planet], belt: &Belt, stations: &[Station]) -> Vec { let mut places = Vec::new(); let mut belt_listed = false; for (p, planet) in planets.iter().enumerate() { if !belt_listed && planet.orbit.a > belt.region.a { places.push(Place { kind: PlaceKind::Belt, name: belt.name.clone() }); belt_listed = true; } places.push(Place { kind: PlaceKind::Planet(p), name: planet.name.clone() }); for (m, moon) in planet.moons.iter().enumerate() { places.push(Place { kind: PlaceKind::Moon(p, m), name: moon.name.clone() }); } for (s, station) in stations.iter().enumerate().filter(|(_, s)| s.planet == p) { places.push(Place { kind: PlaceKind::Station(s), name: station.name.clone() }); } } if !belt_listed { places.push(Place { kind: PlaceKind::Belt, name: belt.name.clone() }); } places } fn random_culture(rng: &mut Rng) -> Culture { Culture::ALL[rng.random_range(..Culture::ALL.len())] } fn roman(n: usize) -> &'static str { ["0", "I", "II", "III", "IV", "V", "VI", "VII", "VIII"].get(n).copied().unwrap_or("IX") } #[cfg(test)] mod tests { use super::*; #[test] fn systems_are_sound() { for seed in 0..300 { let s = generate(seed); assert!((4..=7).contains(&s.planets.len())); let mut outermost = 0.0; for p in &s.planets { assert!( p.orbit.periapsis_distance() > outermost, "seed {seed}: {} crosses", p.name ); outermost = p.orbit.apoapsis_distance(); let hill = p.orbit.periapsis_distance() * (p.mass / (3.0 * s.star.mass)).cbrt(); for m in &p.moons { assert!( m.orbit.a < 0.4 * hill, "seed {seed}: {} strays from {}", m.name, p.name ); assert!(m.orbit.period < p.orbit.period / 5.0); } } let crosses_belt = |p: &Planet| { p.orbit.apoapsis_distance() > s.belt.inner && p.orbit.periapsis_distance() < s.belt.outer }; assert!( !s.planets.iter().any(crosses_belt), "seed {seed}: a planet runs through the belt" ); assert_eq!(s.planets[s.home].people, Some(s.culture), "seed {seed}: home is unsettled"); assert!(!s.planets[s.home].kind.giant); assert!(!s.stations.is_empty()); let belts = s.places.iter().filter(|p| p.kind == PlaceKind::Belt).count(); assert_eq!(belts, 1); for w in s.wrecks_on(0.0) { assert!(w.place < s.places.len()); assert!(w.calling(0.0)); } } } #[test] fn capitals_need_people_and_ground() { for seed in 0..100 { let s = generate(seed); assert_eq!(s.landing(s.home_place()), Landing::Capital(s.culture)); for (i, place) in s.places.iter().enumerate() { let landing = s.landing(i); match place.kind { PlaceKind::Planet(p) if s.planets[p].kind.giant => { assert!(matches!(landing, Landing::Aloft(_) | Landing::Nowhere)); } PlaceKind::Station(_) => assert!(matches!(landing, Landing::Market(_))), PlaceKind::Belt => assert_eq!(landing, Landing::Nowhere), _ => {} } } } } #[test] fn places_are_listed_outward() { let s = generate(8); let distances: Vec = (0..s.places.len()) .filter(|&i| matches!(s.places[i].kind, PlaceKind::Planet(_) | PlaceKind::Belt)) .map(|i| match s.places[i].kind { PlaceKind::Belt => s.belt.region.a, PlaceKind::Planet(p) => s.planets[p].orbit.a, _ => unreachable!(), }) .collect(); assert!(distances.windows(2).all(|w| w[0] < w[1]), "{distances:?}"); } #[test] fn stations_sit_sixty_degrees_from_their_planet() { let s = generate(3); for (i, place) in s.places.iter().enumerate() { if let PlaceKind::Station(k) = place.kind { let planet = s.planets[s.stations[k].planet].orbit.position(40.0); let station = s.position(i, 40.0); let between = (planet.dot(station) / (planet.length() * station.length())).acos(); assert!((between - PI / 3.0).abs() < 1e-9); } } } #[test] fn same_seed_same_system() { let names = |s: System| s.places.iter().map(|p| p.name.clone()).collect::>(); assert_eq!(names(generate(5)), names(generate(5))); } #[test] fn wrecks_come_and_go() { let mut total = 0; let days = 0..400; for seed in 0..6 { let s = generate(seed); for day in days.clone() { let day = day as f64; let here = s.wrecks_on(day); total += here.len(); for w in &here { let life = w.leaves - w.arrived; assert!((BEACON_LIFE.0..BEACON_LIFE.1).contains(&life)); } } // A wreck calling now is long gone in a few months, and others // have come in its place. let now: Vec = s.wrecks_on(10.0).iter().map(|w| w.seed).collect(); let later = s.wrecks_on(110.0); assert!(later.iter().all(|w| !now.contains(&w.seed))); // The same day always has the same wrecks, cached or not. let again: Vec = generate(seed).wrecks_on(10.0).iter().map(|w| w.seed).collect(); assert_eq!(now, again); } let average = total as f64 / (6 * days.len()) as f64; assert!((2.0..3.0).contains(&average), "{average} wrecks calling at once"); } #[test] fn a_failed_beacons_last_fix_still_finds_its_wreck() { let s = generate(1); let none = HashSet::new(); let wreck = s.wrecks_on(0.0)[0].clone(); let later = wreck.leaves + 2.0; let found = |departed| s.wrecks_at(wreck.place, later, departed, &none); assert!(!found(None).contains(&wreck), "its beacon has failed"); assert!(found(Some(0.0)).contains(&wreck), "but it called when you set out"); assert!(!found(Some(wreck.leaves)).contains(&wreck), "not if it had failed already"); assert!(found(Some(0.0)).iter().all(|w| w.place == wreck.place), "only those here"); let stripped = HashSet::from([wreck.seed]); assert!(!s.wrecks_at(wreck.place, later, Some(0.0), &stripped).contains(&wreck)); } #[test] fn graveyards_are_thick_with_wrecks() { let profile = Profile { name: "Ashfall".into(), culture: Culture::Houses, star: &STARS[0], kind: SystemKind::Graveyard, neighbour: None, }; let mut total = 0; let mut at_battle = 0; for seed in 0..6 { let s = generate_as(seed, &profile); let battle = s.battle.unwrap(); for day in (0..300).step_by(3) { let here = s.wrecks_on(day as f64); total += here.len(); at_battle += here.iter().filter(|w| w.place == battle).count(); } } let average = total as f64 / (6 * 100) as f64; assert!((5.0..8.0).contains(&average), "{average} wrecks calling at once"); assert!(at_battle * 2 > total, "most lie where the fighting was"); } }