summaryrefslogtreecommitdiff
path: root/src/space/system.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/space/system.rs')
-rw-r--r--src/space/system.rs1215
1 files changed, 1215 insertions, 0 deletions
diff --git a/src/space/system.rs b/src/space/system.rs
new file mode 100644
index 0000000..7a3742a
--- /dev/null
+++ b/src/space/system.rs
@@ -0,0 +1,1215 @@
+//! 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<Culture>,
+}
+
+/// How a kind of system is laid out and settled.
+struct Rules {
+ planets: RangeInclusive<usize>,
+ /// 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<Culture>,
+ /// 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<Culture>,
+ pub orbit: Orbit,
+ /// In suns.
+ pub mass: f64,
+ pub moons: Vec<Moon>,
+ /// A few sentences for the landing screen.
+ pub description: Vec<String>,
+}
+
+pub struct Moon {
+ pub name: String,
+ pub kind: &'static BodyKind,
+ pub people: Option<Culture>,
+ /// Around its planet.
+ pub orbit: Orbit,
+ pub description: Vec<String>,
+}
+
+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<String>,
+}
+
+/// 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<Planet>,
+ /// 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<Station>,
+ /// Everywhere you can travel to, outward from the star.
+ pub places: Vec<Place>,
+ /// How many wrecks are calling at once, on average.
+ wreck_rate: f64,
+ /// On a battlefield, the place most wrecks turn up.
+ battle: Option<usize>,
+ /// Batches of wrecks already made, by epoch: naming one means
+ /// generating its whole ship.
+ batches: RefCell<BTreeMap<i64, Vec<Wreck>>>,
+}
+
+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<Culture>| 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<usize> {
+ 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<Wreck> {
+ let first = ((day - BEACON_LIFE.1) / WRECK_EPOCH).floor() as i64;
+ let last = (day / WRECK_EPOCH).floor() as i64;
+ let mut wrecks: Vec<Wreck> =
+ (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<u64>) -> Vec<Wreck> {
+ 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<f64>,
+ stripped: &HashSet<u64>,
+ ) -> Vec<Wreck> {
+ 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<Wreck> {
+ 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<Wreck> = (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<Culture> {
+ 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<Station> {
+ 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<Culture>, rng: &mut Rng) -> Vec<String> {
+ 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<Place> {
+ 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<f64> = (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::<Vec<_>>();
+ 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<u64> = 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<u64> = 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");
+ }
+}