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/system.rs | 1215 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1215 insertions(+) create mode 100644 src/space/system.rs (limited to 'src/space/system.rs') 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, +} + +/// 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"); + } +} -- cgit v1.2.3