diff options
Diffstat (limited to 'src/space')
| -rw-r--r-- | src/space/flight.rs | 434 | ||||
| -rw-r--r-- | src/space/galaxy.rs | 573 | ||||
| -rw-r--r-- | src/space/locale.rs | 406 | ||||
| -rw-r--r-- | src/space/mod.rs | 154 | ||||
| -rw-r--r-- | src/space/orbit.rs | 164 | ||||
| -rw-r--r-- | src/space/survey.rs | 365 | ||||
| -rw-r--r-- | src/space/system.rs | 1215 |
7 files changed, 3311 insertions, 0 deletions
diff --git a/src/space/flight.rs b/src/space/flight.rs new file mode 100644 index 0000000..08f678d --- /dev/null +++ b/src/space/flight.rs @@ -0,0 +1,434 @@ +//! The rules of flight around one place: steering, thrust and drag, the +//! mining laser, breaking rocks, and scooping up the ore they drop. Nothing +//! here draws or reads keys; the scene turns keys into `Controls` and draws +//! the result. +//! +//! The feel is arcade rather than physics: you turn on the spot, thrust +//! pushes along your heading, a little drag stops you drifting forever, and +//! speed is capped. To stop, turn to face backwards (`Controls::retro` does +//! that for you) and thrust. Nothing pulls on you; planets and stations are +//! places to land or dock, not wells to fall into. + +use std::f64::consts::TAU; + +use rand::RngExt; + +use super::locale::{DOCK_RADIUS, Locale, Ore, Rock, Size}; +use super::{Vec2, turn_between}; +use crate::seed::{self, Rng}; + +/// Units per second per second at full thrust. +pub const THRUST: f64 = 26.0; +pub const MAX_SPEED: f64 = 32.0; +/// The fraction of your speed lost each second. +const DRAG: f64 = 0.12; +/// Radians per second. +pub const TURN_RATE: f64 = 3.6; +pub const LASER_RANGE: f64 = 24.0; +/// Seconds before uncollected ore drifts off for good. +pub const FLOTSAM_LIFE: f64 = 45.0; +const PICKUP_RANGE: f64 = 2.5; +/// Land, dock or board only when slower than this. +pub const LANDING_SPEED: f64 = 8.0; +/// Board a wreck only when this close. +pub const BOARDING_RANGE: f64 = 4.0; +pub const SPARK_LIFE: f64 = 0.45; +/// How hard the edge of the map pushes you back, per unit you're past it. +const EDGE_PUSH: f64 = 3.0; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Controls { + /// 0 (engines off) to 1 (full). + pub thrust: f64, + /// -1 (anticlockwise) to 1 (clockwise), as a fraction of `TURN_RATE`. + pub turn: f64, + /// Turn to face against your direction of travel, ready to brake. + pub retro: bool, + pub firing: bool, +} + +pub struct Ship { + pub pos: Vec2, + pub vel: Vec2, + /// 0 is up the screen; see `Vec2::from_heading`. + pub heading: f64, + pub thrusting: bool, +} + +/// Ore floating free after a rock breaks, waiting to be scooped up. +pub struct Flotsam { + pub pos: Vec2, + pub vel: Vec2, + pub ore: &'static Ore, + pub age: f64, +} + +/// A spark of engine exhaust. +pub struct Spark { + pub pos: Vec2, + pub vel: Vec2, + pub age: f64, +} + +/// The mining laser, as fired this step. +pub struct Beam { + pub from: Vec2, + pub to: Vec2, + /// Whether it ended on something rather than at full range. + pub hit: bool, +} + +#[derive(Debug, PartialEq)] +pub enum Event { + Shattered { + size: Size, + ore: Option<&'static str>, + }, + Collected(&'static Ore), + /// Flew over ore with no room for it. + HoldFull, +} + +pub struct Flight { + pub locale: Locale, + /// Seconds since you arrived. + pub time: f64, + pub ship: Ship, + pub flotsam: Vec<Flotsam>, + pub sparks: Vec<Spark>, + pub beam: Option<Beam>, + /// How fast the laser cuts: 1 as fitted, more with better lenses. + pub laser_power: f64, + rng: Rng, +} + +impl Flight { + /// Arrives at `locale`, facing whatever's in the middle of it. + pub fn new(locale: Locale, seed: u64) -> Flight { + let pos = locale.arrival; + let heading = if pos.length() > 0.0 { (-pos).heading() } else { 0.0 }; + Flight { + locale, + time: 0.0, + ship: Ship { pos, vel: Vec2::ZERO, heading, thrusting: false }, + flotsam: Vec::new(), + sparks: Vec::new(), + beam: None, + laser_power: 1.0, + rng: seed::rng(seed::derive(seed, "flight", 0)), + } + } + + pub fn speed(&self) -> f64 { + self.ship.vel.length() + } + + /// Whether you're over the planet or moon in the middle of the map. + pub fn over_body(&self) -> bool { + self.locale.body.as_ref().is_some_and(|b| self.ship.pos.length() <= b.radius) + } + + /// Whether you're close enough to the station to dock. + pub fn at_dock(&self) -> bool { + self.locale.dock.is_some() && self.ship.pos.length() <= DOCK_RADIUS + } + + /// The wreck close enough to board, if any, as an index into + /// `locale.wrecks`. + pub fn wreck_alongside(&self) -> Option<usize> { + self.locale.wrecks.iter().position(|w| w.pos.distance(self.ship.pos) <= BOARDING_RANGE) + } + + /// Whether you're beyond the edge of the map, being pushed back. + pub fn past_edge(&self) -> bool { + let e = self.locale.extent; + self.ship.pos.x.abs() > e || self.ship.pos.y.abs() > e + } + + /// Whether you're pointing (near enough) against your direction of travel. + pub fn facing_retro(&self) -> bool { + let backwards = (-self.ship.vel).heading(); + self.speed() < 0.5 || turn_between(self.ship.heading, backwards).abs() < 0.05 + } + + /// Advances the world by `dt` seconds. `room` is how many more tons of + /// ore the hold can take. + pub fn step(&mut self, dt: f64, controls: Controls, room: u32) -> Vec<Event> { + let mut events = Vec::new(); + self.time += dt; + let extent = self.locale.extent; + for rock in &mut self.locale.rocks { + rock.step(dt, extent); + } + self.steer(dt, controls); + self.fire(dt, controls.firing, &mut events); + + for spark in &mut self.sparks { + spark.pos += spark.vel * dt; + spark.age += dt; + } + self.sparks.retain(|s| s.age < SPARK_LIFE); + + let mut collected = 0; + for f in &mut self.flotsam { + f.pos += f.vel * dt; + f.vel = f.vel * (1.0 - 0.3 * dt); + f.age += dt; + if f.pos.distance(self.ship.pos) <= PICKUP_RANGE { + if collected < room { + collected += 1; + f.age = f64::INFINITY; + events.push(Event::Collected(f.ore)); + } else if !events.contains(&Event::HoldFull) { + events.push(Event::HoldFull); + } + } + } + self.flotsam.retain(|f| f.age < FLOTSAM_LIFE); + events + } + + fn steer(&mut self, dt: f64, controls: Controls) { + let ship = &mut self.ship; + ship.heading += controls.turn.clamp(-1.0, 1.0) * TURN_RATE * dt; + if controls.retro && ship.vel.length() > 0.5 { + let off = turn_between(ship.heading, (-ship.vel).heading()); + let most = TURN_RATE * dt; + ship.heading += off.clamp(-most, most); + } + ship.heading = ship.heading.rem_euclid(TAU); + + let ahead = Vec2::from_heading(ship.heading); + let thrust = controls.thrust.clamp(0.0, 1.0); + ship.thrusting = thrust > 0.0; + if ship.thrusting { + ship.vel += ahead * (THRUST * thrust * dt); + // Exhaust: a spark out the back, thrown off a little to one side. + let side = Vec2::new(-ahead.y, ahead.x) * self.rng.random_range(-2.5..2.5); + self.sparks.push(Spark { + pos: ship.pos - ahead, + vel: ship.vel - ahead * self.rng.random_range(10.0..16.0) + side, + age: 0.0, + }); + } + // The edge of the map: a spring that pushes you back in. + let e = self.locale.extent; + let over = |v: f64| (v.abs() - e).max(0.0) * v.signum(); + ship.vel -= Vec2::new(over(ship.pos.x), over(ship.pos.y)) * (EDGE_PUSH * dt); + + ship.vel = ship.vel * (1.0 - DRAG * dt); + if ship.vel.length() > MAX_SPEED { + ship.vel = ship.vel.normalised() * MAX_SPEED; + } + ship.pos += ship.vel * dt; + } + + /// Casts the laser along your heading, wearing down the first rock it + /// meets. The planet or station in the middle stops it too, unharmed. + fn fire(&mut self, dt: f64, firing: bool, events: &mut Vec<Event>) { + self.beam = None; + if !firing { + return; + } + let ahead = Vec2::from_heading(self.ship.heading); + let from = self.ship.pos + ahead; + let mut nearest: Option<(f64, Option<usize>)> = None; + let mut consider = |centre: Vec2, r: f64, what: Option<usize>| { + if let Some(d) = ray_hits_circle(from, ahead, centre, r) + && d <= LASER_RANGE + && nearest.is_none_or(|(n, _)| d < n) + { + nearest = Some((d, what)); + } + }; + for (i, rock) in self.locale.rocks.iter().enumerate() { + consider(rock.pos, rock.size.radius(), Some(i)); + } + if let Some(r) = self.locale.solid_radius() { + consider(Vec2::ZERO, r, None); + } + let reach = nearest.map_or(LASER_RANGE, |(d, _)| d); + self.beam = Some(Beam { from, to: from + ahead * reach, hit: nearest.is_some() }); + if let Some((_, Some(i))) = nearest { + self.locale.rocks[i].integrity -= dt * self.laser_power; + if self.locale.rocks[i].integrity <= 0.0 { + self.shatter(i, events); + } + } + } + + /// Breaks a rock into two smaller ones drifting apart, and spills its + /// ore, if it had any. + fn shatter(&mut self, i: usize, events: &mut Vec<Event>) { + let rock = self.locale.rocks.swap_remove(i); + let rng = &mut self.rng; + let vel = rock.velocity(); + if let Some(size) = rock.size.smaller() { + let apart = Vec2::from_heading(rng.random_range(0.0..TAU)); + for side in [-1.0, 1.0] { + let pos = rock.pos + apart * (side * rock.size.radius() * 0.6); + let child = if rock.circling == 0.0 { + let kick = apart * (side * rng.random_range(0.5..1.5)); + Rock::new(pos, rock.vel + kick, 0.0, size, rock.ore) + } else { + // Keep circling at about the parent's distance. + let circling = rock.circling * rng.random_range(0.9..1.1); + Rock::new(pos, Vec2::ZERO, circling, size, rock.ore) + }; + self.locale.rocks.push(child); + } + } + if let Some(ore) = rock.ore { + let chunks = match rock.size { + Size::Small => 1, + Size::Medium => 2, + Size::Large => 3, + }; + for _ in 0..chunks { + let drift = + Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(1.0..4.0); + self.flotsam.push(Flotsam { pos: rock.pos, vel: vel + drift, ore, age: 0.0 }); + } + } + events.push(Event::Shattered { size: rock.size, ore: rock.ore.map(|o| o.name) }); + } +} + +/// How far along a ray (from `from`, along unit vector `dir`) it first +/// touches a circle, if it does. Zero if it starts inside. +fn ray_hits_circle(from: Vec2, dir: Vec2, centre: Vec2, r: f64) -> Option<f64> { + let to_centre = centre - from; + let along = to_centre.dot(dir); + let miss_sq = to_centre.dot(to_centre) - along * along; + if along < -r || miss_sq > r * r { + return None; + } + Some((along - (r * r - miss_sq).sqrt()).max(0.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::locale::{self, ORES}; + use crate::space::system::{self, PlaceKind}; + + const DT: f64 = 1.0 / 30.0; + + /// Flying in the belt of system 1, with the rocks cleared away. + fn flight() -> Flight { + let s = system::generate(1); + let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + let mut locale = locale::generate(&s, belt, 0.0, None, &Default::default()); + locale.rocks.clear(); + Flight::new(locale, 1) + } + + fn run(f: &mut Flight, seconds: f64, controls: Controls) -> Vec<Event> { + let mut events = Vec::new(); + for _ in 0..(seconds / DT).round() as usize { + events.extend(f.step(DT, controls, 99)); + } + events + } + + /// A rock that sits still at `pos`. + fn rock_at(pos: Vec2, size: Size) -> Rock { + Rock::new(pos, Vec2::ZERO, 0.0, size, Some(&ORES[0])) + } + + #[test] + fn thrust_pushes_along_the_heading_up_to_a_limit() { + let mut f = flight(); + f.ship.heading = TAU / 4.0; + run(&mut f, 1.0, Controls { thrust: 1.0, ..Controls::default() }); + assert!(f.ship.vel.x > 15.0 && f.ship.vel.y.abs() < 1e-6, "heading right goes right"); + run(&mut f, 3.0, Controls { thrust: 1.0, ..Controls::default() }); + assert!((f.speed() - MAX_SPEED).abs() < 1e-6, "speed is capped"); + } + + #[test] + fn drag_slows_you_when_coasting() { + let mut f = flight(); + f.ship.vel = Vec2::new(20.0, 0.0); + run(&mut f, 3.0, Controls::default()); + assert!(f.speed() < 15.0 && f.speed() > 5.0, "coasting decays, slowly: {}", f.speed()); + } + + #[test] + fn retro_turns_you_to_face_backwards() { + let mut f = flight(); + f.ship.vel = Vec2::new(0.0, -10.0); + f.ship.heading = 0.0; + assert!(!f.facing_retro()); + run(&mut f, 1.5, Controls { retro: true, ..Controls::default() }); + assert!(f.facing_retro(), "heading {} after retro", f.ship.heading); + } + + #[test] + fn the_edge_pushes_you_back() { + let mut f = flight(); + f.ship.pos = Vec2::new(f.locale.extent - 5.0, 0.0); + f.ship.vel = Vec2::new(MAX_SPEED, 0.0); + run(&mut f, 5.0, Controls::default()); + assert!(f.ship.vel.x < 0.0, "turned round"); + assert!(f.ship.pos.x < f.locale.extent + 30.0); + } + + #[test] + fn mining_breaks_rocks_and_ore_can_be_scooped_up() { + let mut f = flight(); + f.ship.pos = Vec2::new(50.0, 0.0); + f.ship.heading = 0.0; + f.locale.rocks = vec![rock_at(Vec2::new(50.0, -10.0), Size::Medium)]; + // Long enough to break the rock, not long enough to break its pieces. + let events = run(&mut f, 1.1, Controls { firing: true, ..Controls::default() }); + assert!(events.contains(&Event::Shattered { size: Size::Medium, ore: Some("iron ore") })); + assert_eq!(f.locale.rocks.len(), 2, "a medium rock breaks in two"); + assert!(f.locale.rocks.iter().all(|r| r.size == Size::Small)); + assert_eq!(f.flotsam.len(), 2, "a medium rock spills two chunks"); + + f.ship.pos = f.flotsam[0].pos; + let events = f.step(DT, Controls::default(), 1); + assert!(events.iter().any(|e| matches!(e, Event::Collected(_)))); + } + + #[test] + fn a_full_hold_leaves_ore_floating() { + let mut f = flight(); + f.flotsam = vec![Flotsam { pos: f.ship.pos, vel: Vec2::ZERO, ore: &ORES[0], age: 0.0 }]; + let events = f.step(DT, Controls::default(), 0); + assert_eq!(events, vec![Event::HoldFull]); + assert_eq!(f.flotsam.len(), 1); + } + + #[test] + fn the_laser_stops_at_the_first_rock() { + let mut f = flight(); + f.ship.pos = Vec2::new(50.0, 0.0); + f.ship.heading = 0.0; + f.locale.rocks = vec![ + rock_at(Vec2::new(50.0, -15.0), Size::Small), + rock_at(Vec2::new(50.0, -6.0), Size::Small), + ]; + f.step(DT, Controls { firing: true, ..Controls::default() }, 0); + let beam = f.beam.as_ref().unwrap(); + assert!(beam.hit && beam.to.y > -6.0 && beam.to.y < -5.0, "beam ends at {:?}", beam.to); + assert!(f.locale.rocks[1].integrity < f.locale.rocks[0].integrity); + } + + #[test] + fn planets_stop_the_laser_and_can_be_landed_on() { + let s = system::generate(1); + let mut f = + Flight::new(locale::generate(&s, s.home_place(), 0.0, None, &Default::default()), 1); + f.locale.rocks.clear(); + let radius = f.locale.body.as_ref().unwrap().radius; + assert!(!f.over_body()); + f.step(DT, Controls { firing: true, ..Controls::default() }, 0); + let beam = f.beam.as_ref().unwrap(); + assert!(beam.hit, "you arrive facing the planet, in range"); + assert!((beam.to.length() - radius).abs() < 1e-6); + f.ship.pos = Vec2::new(0.0, radius - 1.0); + assert!(f.over_body()); + } +} diff --git a/src/space/galaxy.rs b/src/space/galaxy.rs new file mode 100644 index 0000000..bb06667 --- /dev/null +++ b/src/space/galaxy.rs @@ -0,0 +1,573 @@ +//! The galaxy: about ninety star systems, the lanes between them, and the +//! four peoples' territories. +//! +//! You can't fly between systems; you jump, and only along a lane. Lanes +//! join near neighbours: every pair in the relative neighbourhood graph +//! (no third star closer to both than they are to each other), which keeps +//! the map connected and its lanes from crossing, plus some of the extra +//! pairs of the Gabriel graph, for a few loops. To reach a far system you +//! jump through the ones between. +//! +//! Stars are scattered across a lopsided disc, denser in some places than +//! others, with a few empty voids that the lanes have to bridge. +//! +//! Each people has a seat: the system the universe started you in is one, +//! for whoever holds it, and the other three are spread out from it. +//! Territory grows from each seat along the lanes, each people at its own +//! pace, so territories come out in ragged blobs of different sizes. Every +//! system ends up held by someone. +//! +//! What a system is like (its kind) depends on where it lies: settled +//! systems around the seats, frontier and desolate ones further out, and +//! old battlefields along the borders, where the neighbours settle too. +//! +//! A galaxy only knows what it takes to draw the map. Each system is +//! generated whole when you need it, from its `Site`. + +use std::cmp::Reverse; +use std::collections::{BTreeSet, BinaryHeap}; +use std::f64::consts::TAU; + +use rand::RngExt; +use rand::seq::{IndexedRandom, SliceRandom}; + +use super::Vec2; +use super::system::{self, Profile, StarKind, System, SystemKind}; +use crate::names::Culture; +use crate::seed::{self, Rng}; + +/// How many systems a galaxy has, if they all fit. +pub const SYSTEMS: usize = 90; +/// Half the galaxy's width and height, in light-years. +pub const HALF_WIDTH: f64 = 100.0; +pub const HALF_HEIGHT: f64 = 62.0; +/// No two stars are closer than this, in light-years. +const SPACING: f64 = 9.0; +/// Lanes per system, at most, counting only the extra loops. +const MAX_LANES: usize = 4; + +/// A star system as the map knows it, before it's generated. +pub struct Site { + pub name: String, + /// In light-years from the middle of the galaxy, y up. + pub pos: Vec2, + /// Who holds it. + pub culture: Culture, + pub kind: SystemKind, + pub star: &'static StarKind, + /// The systems a lane joins it to. + pub lanes: Vec<usize>, + /// For a system on a border: the people across it. + pub neighbour: Option<Culture>, + seed: u64, +} + +pub struct Galaxy { + pub sites: Vec<Site>, + /// The system the universe started everyone in, before there was a + /// galaxy. Saves from then are there. + pub home: usize, + /// Each people's seat, in `Culture::ALL` order. + seats: [usize; 4], +} + +impl Galaxy { + /// Generates a system in full. + pub fn system(&self, site: usize) -> System { + let s = &self.sites[site]; + if site == self.home { + return system::generate(s.seed); + } + let profile = Profile { + name: s.name.clone(), + culture: s.culture, + star: s.star, + kind: s.kind, + neighbour: s.neighbour, + }; + system::generate_as(s.seed, &profile) + } + + /// The seat of a people. + pub fn seat(&self, culture: Culture) -> usize { + self.seats[culture as usize] + } + + pub fn is_seat(&self, site: usize) -> bool { + self.seats.contains(&site) + } + + /// Light-years between two systems. + pub fn distance(&self, a: usize, b: usize) -> f64 { + self.sites[a].pos.distance(self.sites[b].pos) + } + + /// Days a jump takes from one system to another, along a lane. + pub fn jump_days(&self, a: usize, b: usize) -> f64 { + 1.0 + self.distance(a, b) / 5.0 + } + + /// Days to jump along a route from `from`. + pub fn route_days(&self, from: usize, route: &[usize]) -> f64 { + let mut at = from; + let mut days = 0.0; + for &next in route { + days += self.jump_days(at, next); + at = next; + } + days + } + + /// The systems you know the way to: those you've been to, and those a + /// lane joins to them. + pub fn charted(&self, visited: &BTreeSet<usize>) -> Vec<bool> { + let mut charted = vec![false; self.sites.len()]; + for &v in visited.iter().filter(|&&v| v < self.sites.len()) { + charted[v] = true; + for &n in &self.sites[v].lanes { + charted[n] = true; + } + } + charted + } + + /// The fewest jumps from `from` to `to`, going only through systems + /// that are `passable`, and the shortest such way. Doesn't include + /// `from`, ends with `to`, and is empty if they're the same. + pub fn route( + &self, + from: usize, + to: usize, + passable: impl Fn(usize) -> bool, + ) -> Option<Vec<usize>> { + self.search(from, passable, |s| s == to) + } + + /// The fewest jumps to the nearest system that is `wanted`, through any + /// system at all. + pub fn nearest(&self, from: usize, wanted: impl Fn(usize) -> bool) -> Option<Vec<usize>> { + self.search(from, |_| true, wanted) + } + + fn search( + &self, + from: usize, + passable: impl Fn(usize) -> bool, + goal: impl Fn(usize) -> bool, + ) -> Option<Vec<usize>> { + let n = self.sites.len(); + // Jumps first, then distance, in hundredths of a light-year. + let cost = |jumps: u64, ly: f64| jumps * 1_000_000_000 + (ly * 100.0) as u64; + let mut best = vec![u64::MAX; n]; + let mut came_from = vec![usize::MAX; n]; + let mut queue = BinaryHeap::new(); + best[from] = 0; + queue.push(Reverse((0, 0u64, 0.0f64.to_bits(), from))); + while let Some(Reverse((c, jumps, ly, at))) = queue.pop() { + if c > best[at] { + continue; + } + if goal(at) { + let mut route = vec![]; + let mut step = at; + while step != from { + route.push(step); + step = came_from[step]; + } + route.reverse(); + return Some(route); + } + for &next in &self.sites[at].lanes { + if !passable(next) { + continue; + } + let ly = f64::from_bits(ly) + self.distance(at, next); + let c = cost(jumps + 1, ly); + if c < best[next] { + best[next] = c; + came_from[next] = at; + queue.push(Reverse((c, jumps + 1, ly.to_bits(), next))); + } + } + } + None + } + + /// The corners of a box round every star, in light-years. + pub fn bounds(&self) -> (Vec2, Vec2) { + let mut low = Vec2::new(f64::MAX, f64::MAX); + let mut high = Vec2::new(f64::MIN, f64::MIN); + for s in &self.sites { + low = Vec2::new(low.x.min(s.pos.x), low.y.min(s.pos.y)); + high = Vec2::new(high.x.max(s.pos.x), high.y.max(s.pos.y)); + } + (low, high) + } +} + +pub fn generate(universe: u64) -> Galaxy { + let home_seed = seed::derive(universe, "system", 0); + let home_system = system::generate(home_seed); + let mut rng = seed::rng(seed::derive(universe, "galaxy", 0)); + + let points = scatter(&mut rng); + let n = points.len(); + let lanes = join(&points, &mut rng); + + // The seats, as far apart as can be without crowding the rim. + let seat_sites = place_seats(&points, &mut rng); + let mut cultures = Culture::ALL; + cultures.shuffle(&mut rng); + // Whoever holds the original system has their seat there. + let mut seats = [0; 4]; + for (&site, &culture) in seat_sites.iter().zip(&cultures) { + seats[culture as usize] = site; + } + let home = seats[home_system.culture as usize]; + + let owner = grow(&lanes, &points, &seats, universe, &mut rng); + + // What each system is, given where it lies. + let hops_from_seat = |i: usize| { + let seat = seats[owner[i] as usize]; + if i == seat { + 0 + } else if lanes[i].contains(&seat) { + 1 + } else { + 2 + } + }; + let mut names: BTreeSet<String> = BTreeSet::new(); + names.insert(home_system.name.clone()); + let mut sites = Vec::with_capacity(n); + for i in 0..n { + let culture = owner[i]; + let mut across: Vec<Culture> = + lanes[i].iter().map(|&j| owner[j]).filter(|&c| c != culture).collect(); + across.sort_by_key(|&c| c as usize); + // The people most of its foreign lanes lead to. + let neighbour = Culture::ALL + .into_iter() + .filter(|c| across.contains(c)) + .max_by_key(|&c| across.iter().filter(|&&x| x == c).count()); + let kind = if seats.contains(&i) { + SystemKind::Seat + } else { + pick_kind(hops_from_seat(i), neighbour.is_some(), lanes[i].len() == 1, &mut rng) + }; + let (name, star, seed) = if i == home { + (home_system.name.clone(), home_system.star, home_seed) + } else { + let name = loop { + let name = culture.word(&mut rng); + if names.insert(name.clone()) { + break name; + } + }; + let star = system::random_star(kind, &mut rng); + (name, star, seed::derive(universe, "system", i as u64 + 1)) + }; + sites.push(Site { + name, + pos: points[i], + culture, + kind, + star, + lanes: lanes[i].clone(), + neighbour, + seed, + }); + } + Galaxy { sites, home, seats } +} + +/// Where the stars are: scattered over a lopsided disc, clumped here and +/// thin there, with a few empty voids. +fn scatter(rng: &mut Rng) -> Vec<Vec2> { + let lobes = [ + (rng.random_range(0.06..0.14), rng.random_range(0.0..TAU)), + (rng.random_range(0.03..0.08), rng.random_range(0.0..TAU)), + ]; + let inside = |p: Vec2| { + let (x, y) = (p.x / HALF_WIDTH, p.y / HALF_HEIGHT); + let angle = y.atan2(x); + let edge = 1.0 + + lobes[0].0 * (2.0 * angle + lobes[0].1).sin() + + lobes[1].0 * (3.0 * angle + lobes[1].1).sin(); + x.hypot(y) < edge + }; + let voids: Vec<(Vec2, f64)> = (0..rng.random_range(2..=3)) + .map(|_| { + let angle = rng.random_range(0.0..TAU); + let r = rng.random_range(0.2..0.7); + let at = Vec2::new(angle.cos() * r * HALF_WIDTH, angle.sin() * r * HALF_HEIGHT); + (at, rng.random_range(9.0..15.0)) + }) + .collect(); + let phase = (rng.random_range(0.0..TAU), rng.random_range(0.0..TAU)); + let clumping = |p: Vec2| { + let wave = (p.x / 23.0 + phase.0).sin() * (p.y / 17.0 + phase.1).sin(); + 0.45 + 0.55 * (0.5 + 0.5 * wave) + }; + let mut points: Vec<Vec2> = Vec::new(); + for _ in 0..200_000 { + if points.len() == SYSTEMS { + break; + } + let p = Vec2::new( + rng.random_range(-1.2 * HALF_WIDTH..1.2 * HALF_WIDTH), + rng.random_range(-1.2 * HALF_HEIGHT..1.2 * HALF_HEIGHT), + ); + if !inside(p) + || !rng.random_bool(clumping(p)) + || voids.iter().any(|&(c, r)| p.distance(c) < r) + || points.iter().any(|q| q.distance(p) < SPACING) + { + continue; + } + points.push(p); + } + points +} + +/// The lanes: every pair in the relative neighbourhood graph, and some of +/// the Gabriel graph's extra pairs, shortest first. Each system's list is +/// sorted, so it's the same every time. +fn join(points: &[Vec2], rng: &mut Rng) -> Vec<Vec<usize>> { + let n = points.len(); + let d = |i: usize, j: usize| points[i].distance(points[j]); + let mut lanes = vec![Vec::new(); n]; + let mut extras = Vec::new(); + for i in 0..n { + for j in i + 1..n { + let dij = d(i, j); + let others = (0..n).filter(|&k| k != i && k != j); + if !others.clone().any(|k| d(i, k).max(d(j, k)) < dij) { + lanes[i].push(j); + lanes[j].push(i); + } else if !others.into_iter().any(|k| d(i, k).powi(2) + d(j, k).powi(2) < dij * dij) { + extras.push((dij, i, j)); + } + } + } + extras.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (_, i, j) in extras { + if lanes[i].len() < MAX_LANES && lanes[j].len() < MAX_LANES && rng.random_bool(0.45) { + lanes[i].push(j); + lanes[j].push(i); + } + } + for l in &mut lanes { + l.sort_unstable(); + } + lanes +} + +/// Four seats spread as far apart as they'll go, none of them out on the +/// very rim. +fn place_seats(points: &[Vec2], rng: &mut Rng) -> [usize; 4] { + let rim = |p: Vec2| (p.x / HALF_WIDTH).hypot(p.y / HALF_HEIGHT); + let mut pool: Vec<usize> = (0..points.len()).filter(|&i| rim(points[i]) < 0.7).collect(); + if pool.len() < 4 { + pool = (0..points.len()).collect(); + } + let mut best = [0, 1, 2, 3]; + let mut best_gap = f64::MIN; + for _ in 0..400 { + pool.shuffle(rng); + let pick = [pool[0], pool[1], pool[2], pool[3]]; + let mut gap = f64::MAX; + for a in 0..4 { + for b in a + 1..4 { + gap = gap.min(points[pick[a]].distance(points[pick[b]])); + } + } + if gap > best_gap { + best_gap = gap; + best = pick; + } + } + best +} + +/// Who holds each system: each people spreads out from its seat along the +/// lanes, at a pace of its own, and claims whatever it reaches first. +fn grow( + lanes: &[Vec<usize>], + points: &[Vec2], + seats: &[usize; 4], + universe: u64, + rng: &mut Rng, +) -> Vec<Culture> { + let pace: Vec<f64> = (0..4).map(|_| rng.random_range(0.75..1.3)).collect(); + let mut owner: Vec<Option<Culture>> = vec![None; points.len()]; + let mut queue = BinaryHeap::new(); + for c in Culture::ALL { + queue.push(Reverse((0u64, seats[c as usize], c as usize))); + } + while let Some(Reverse((cost, at, c))) = queue.pop() { + if owner[at].is_some() { + continue; + } + owner[at] = Some(Culture::ALL[c]); + for &next in &lanes[at] { + if owner[next].is_some() { + continue; + } + // Some lanes are harder going for some peoples than others. + let rough = seed::derive(universe, "border", (at * points.len() + next) as u64 * 4); + let rough = 0.65 + 0.8 * ((rough >> 11) as f64 / (1u64 << 53) as f64); + let step = points[at].distance(points[next]) * pace[c] * rough; + queue.push(Reverse((cost + (step * 1000.0) as u64, next, c))); + } + } + // The lanes join everything, so everything is reached; but just in case. + owner.into_iter().map(|o| o.unwrap_or(Culture::Drift)).collect() +} + +/// A kind for a system `hops` from its own seat (two meaning two or more), +/// maybe on a border, maybe at the end of a lane to nowhere else. +fn pick_kind(hops: u32, border: bool, dead_end: bool, rng: &mut Rng) -> SystemKind { + use SystemKind::*; + let mut weights: Vec<(SystemKind, u32)> = if hops <= 1 { + vec![(Settled, 70), (Frontier, 8), (Rubble, 16), (Desolate, 4), (Graveyard, 2)] + } else if border { + vec![(Settled, 22), (Frontier, 22), (Rubble, 12), (Desolate, 20), (Graveyard, 24)] + } else { + vec![(Settled, 40), (Frontier, 26), (Rubble, 16), (Desolate, 14), (Graveyard, 4)] + }; + if dead_end { + for (kind, w) in &mut weights { + if matches!(kind, Frontier | Desolate) { + *w *= 2; + } + } + } + weights.choose_weighted(rng, |&(_, w)| w).unwrap().0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::system::PlaceKind; + + #[test] + fn galaxies_are_whole() { + for universe in 0..12 { + let g = generate(universe); + let n = g.sites.len(); + assert!(n >= SYSTEMS - 5, "universe {universe}: only {n} systems"); + // Every system can be reached from every other. + for to in 0..n { + assert!(g.route(g.home, to, |_| true).is_some(), "universe {universe}: {to}"); + } + // Lanes go both ways, and no star sits on top of another. + for (i, s) in g.sites.iter().enumerate() { + assert!(!s.lanes.is_empty()); + for &j in &s.lanes { + assert!(g.sites[j].lanes.contains(&i)); + } + for j in i + 1..n { + assert!(g.distance(i, j) >= SPACING); + } + } + let names: BTreeSet<&str> = g.sites.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names.len(), n, "universe {universe}: names repeat"); + } + } + + #[test] + fn each_people_holds_one_patch_round_its_seat() { + for universe in 0..12 { + let g = generate(universe); + for c in Culture::ALL { + let seat = g.seat(c); + assert_eq!(g.sites[seat].culture, c); + assert_eq!(g.sites[seat].kind, SystemKind::Seat); + // Every system they hold is reachable without leaving + // their space. + for (i, s) in g.sites.iter().enumerate().filter(|(_, s)| s.culture == c) { + let inside = g.route(seat, i, |j| g.sites[j].culture == c); + assert!(inside.is_some(), "universe {universe}: {} cut off", s.name); + } + let held = g.sites.iter().filter(|s| s.culture == c).count(); + assert!(held >= 6, "universe {universe}: {} hold only {held}", c.name()); + } + } + } + + #[test] + fn the_original_system_is_its_peoples_seat() { + for universe in [0, 7, 1312] { + let g = generate(universe); + let old = crate::space::home_system(universe); + let s = g.system(g.home); + assert_eq!(s.name, old.name); + assert_eq!(s.places.len(), old.places.len()); + assert_eq!(g.seat(old.culture), g.home); + assert_eq!(g.sites[g.home].name, old.name); + } + } + + #[test] + fn systems_are_what_the_map_says() { + let g = generate(3); + for (i, site) in g.sites.iter().enumerate() { + let s = g.system(i); + assert_eq!( + (s.name.as_str(), s.culture, s.kind), + (site.name.as_str(), site.culture, site.kind) + ); + assert_eq!(s.star.name, site.star.name); + assert_eq!(s.sells_fuel(), site.kind.sells_fuel(), "{}: {:?}", s.name, s.kind); + assert!(matches!( + s.places[s.edge_place()].kind, + PlaceKind::Planet(_) | PlaceKind::Belt + )); + } + } + + #[test] + fn there_is_variety() { + let mut seen = BTreeSet::new(); + for universe in 0..4 { + for s in generate(universe).sites { + seen.insert(format!("{:?}", s.kind)); + } + } + assert_eq!(seen.len(), SystemKind::ALL.len(), "{seen:?}"); + } + + #[test] + fn routes_take_the_fewest_jumps_through_what_you_allow() { + let g = generate(5); + let far = (0..g.sites.len()) + .max_by(|&a, &b| g.distance(g.home, a).total_cmp(&g.distance(g.home, b))); + let far = far.unwrap(); + let route = g.route(g.home, far, |_| true).unwrap(); + assert!(route.len() >= 3, "the far side is several jumps away"); + assert_eq!(*route.last().unwrap(), far); + for pair in + std::iter::once(g.home).chain(route.iter().copied()).collect::<Vec<_>>().windows(2) + { + assert!(g.sites[pair[0]].lanes.contains(&pair[1]), "jumps follow lanes"); + } + assert_eq!(g.route(g.home, g.home, |_| true), Some(vec![])); + assert_eq!(g.route(g.home, far, |s| s == g.home), None, "blocked"); + let mut visited = BTreeSet::new(); + visited.insert(g.home); + let charted = g.charted(&visited); + assert_eq!(charted.iter().filter(|&&c| c).count(), 1 + g.sites[g.home].lanes.len()); + } + + #[test] + fn same_universe_same_galaxy() { + let a = generate(42); + let b = generate(42); + let key = |g: &Galaxy| { + g.sites.iter().map(|s| (s.name.clone(), s.lanes.clone(), s.kind)).collect::<Vec<_>>() + }; + assert_eq!(key(&a), key(&b)); + } +} diff --git a/src/space/locale.rs b/src/space/locale.rs new file mode 100644 index 0000000..d6339b9 --- /dev/null +++ b/src/space/locale.rs @@ -0,0 +1,406 @@ +//! The local map of one place: what you fly around in once you arrive. +//! +//! A planet's orbit has the planet filling the middle of the map, a few +//! rocks circling it, and its moons hanging far off in the sky. A moon is +//! the same, smaller, with its planet looming behind. The belt is all rocks. +//! A station sits among the Trojan asteroids that collect at its Lagrange +//! point. +//! +//! The layout (which way things lie) is fixed for each place. The rocks are +//! regenerated for each day you visit, so a mined-out place fills up again +//! if you come back another day. The wrecks are whichever are calling that +//! day, and any that were calling when you set out for the place, less +//! those you've stripped: a hulk you've emptied is still there while you +//! are, and gone when you come back. Each wreck keeps its own spot for as +//! long as it lasts. The star lights everything from the direction it +//! really is in on that date. + +use std::collections::HashSet; +use std::f64::consts::TAU; + +use rand::RngExt; +use rand::seq::IndexedRandom; + +use super::Vec2; +use super::system::{BodyKind, PlaceKind, Rgb, System, Wreck}; +use crate::names::{self, Culture}; +use crate::seed::{self, Rng}; + +#[derive(Debug, PartialEq)] +pub struct Ore { + pub name: &'static str, + /// Scrip per ton, before local prices. + pub value: u32, + pub rgb: Rgb, + weight: u32, +} + +#[rustfmt::skip] +pub const ORES: &[Ore] = &[ + Ore { name: "iron ore", value: 4, rgb: (178, 112, 88), weight: 10 }, + Ore { name: "nickel", value: 6, rgb: (176, 182, 160), weight: 7 }, + Ore { name: "iridium", value: 14, rgb: (200, 214, 236), weight: 4 }, + Ore { name: "saltglass", value: 22, rgb: (240, 222, 196), weight: 2 }, + Ore { name: "vesper crystal", value: 35, rgb: (196, 124, 255), weight: 1 }, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Size { + Small, + Medium, + Large, +} + +impl Size { + pub fn radius(self) -> f64 { + match self { + Size::Small => 0.8, + Size::Medium => 1.6, + Size::Large => 2.6, + } + } + + /// Seconds of laser fire to break it. + pub fn toughness(self) -> f64 { + match self { + Size::Small => 0.4, + Size::Medium => 1.0, + Size::Large => 2.0, + } + } + + /// What it breaks into, if anything. + pub fn smaller(self) -> Option<Size> { + match self { + Size::Small => None, + Size::Medium => Some(Size::Small), + Size::Large => Some(Size::Medium), + } + } +} + +pub struct Rock { + pub pos: Vec2, + /// Drift in a straight line, in units per second. + pub vel: Vec2, + /// Turning around the middle of the map, in radians per second: rocks in + /// a planet's orbit circle it rather than drifting through it. + pub circling: f64, + pub size: Size, + pub ore: Option<&'static Ore>, + /// Seconds of laser fire left before it breaks. + pub integrity: f64, +} + +impl Rock { + pub fn new(pos: Vec2, vel: Vec2, circling: f64, size: Size, ore: Option<&'static Ore>) -> Rock { + Rock { pos, vel, circling, size, ore, integrity: size.toughness() } + } + + pub fn velocity(&self) -> Vec2 { + self.vel + Vec2::new(-self.pos.y, self.pos.x) * self.circling + } + + pub fn step(&mut self, dt: f64, extent: f64) { + self.pos = self.pos.rotated(self.circling * dt) + self.vel * dt; + // Drifters that leave one side come back in the other. + let wrap = |v: f64| (v + extent).rem_euclid(2.0 * extent) - extent; + self.pos = Vec2::new(wrap(self.pos.x), wrap(self.pos.y)); + } +} + +/// The planet or moon in the middle of the map. It sits at the origin. +pub struct Body { + pub name: String, + pub kind: &'static BodyKind, + pub radius: f64, + /// Whether anyone lives there to trade with. + pub settled: bool, +} + +/// The station in the middle of the map. It sits at the origin. +pub struct Dock { + pub name: String, + pub culture: Culture, +} + +/// How close to the middle of a station you must be to dock. +pub const DOCK_RADIUS: f64 = 5.0; + +/// Something far off in the sky: a moon seen from its planet, or the +/// planet behind a moon. You can't fly to it here; it's scenery that barely +/// moves as you do. +pub struct Backdrop { + pub name: String, + pub kind: &'static BodyKind, + /// Where it hangs, relative to the middle of the screen when you're at + /// the middle of the map. + pub pos: Vec2, + pub radius: f64, +} + +/// How much backdrops move as you fly: a tenth as fast as the map. +pub const BACKDROP_PARALLAX: f64 = 0.1; + +pub struct LocalWreck { + pub wreck: Wreck, + pub pos: Vec2, + /// Which way its bow points, for drawing. + pub heading: f64, +} + +pub struct Locale { + pub place: usize, + pub name: String, + /// Where you are, as you'd say it: "in orbit around Qeth, a desert world". + pub what: String, + /// Half the width of the map. Past this the edge pushes you back, and + /// drifting rocks wrap round. + pub extent: f64, + /// Unit vector towards the star, on the map (y down). + pub sun: Vec2, + pub body: Option<Body>, + pub dock: Option<Dock>, + pub backdrops: Vec<Backdrop>, + pub rocks: Vec<Rock>, + pub wrecks: Vec<LocalWreck>, + /// Where you come in. + pub arrival: Vec2, +} + +impl Locale { + /// What you'd crash the laser into, or land on: the body or the station, + /// as a circle at the origin. + pub fn solid_radius(&self) -> Option<f64> { + self.body.as_ref().map(|b| b.radius).or(self.dock.as_ref().map(|_| DOCK_RADIUS - 2.0)) + } +} + +/// From system coordinates (y up) to map coordinates (y down). +fn to_map(v: Vec2) -> Vec2 { + Vec2::new(v.x, -v.y) +} + +/// The map of `place` on `day`, having set out for it on `departed`, +/// without the wrecks you've `stripped`. +pub fn generate( + system: &System, + place: usize, + day: f64, + departed: Option<f64>, + stripped: &HashSet<u64>, +) -> Locale { + let layout_seed = seed::derive(system.seed, "place", place as u64); + let mut rng = seed::rng(layout_seed); + let mut rocks_rng = seed::rng(seed::derive(layout_seed, "rocks", day.floor() as u64)); + + let here = system.position(place, day); + let sun = to_map(-here).normalised(); + let name = system.places[place].name.clone(); + + let mut body = None; + let mut dock = None; + let mut backdrops = Vec::new(); + let mut rocks = Vec::new(); + let (what, extent); + match system.places[place].kind { + PlaceKind::Planet(p) => { + let planet = &system.planets[p]; + what = format!("in orbit around {}, {}", planet.name, names::a(planet.kind.name)); + let radius = planet.kind.size; + extent = 90.0 + radius * 2.0; + body = Some(Body { + name: planet.name.clone(), + kind: planet.kind, + radius, + settled: planet.people.is_some(), + }); + for (m, moon) in planet.moons.iter().enumerate() { + let toward = to_map(moon.orbit.position(day)).normalised(); + backdrops.push(Backdrop { + name: moon.name.clone(), + kind: moon.kind, + pos: toward * (40.0 + 12.0 * m as f64), + radius: moon.kind.size * 0.25, + }); + } + let count = if planet.kind.giant { + rocks_rng.random_range(14..=24) + } else { + rocks_rng.random_range(2..=8) + }; + ring(&mut rocks, &mut rocks_rng, count, radius + 8.0, radius + 30.0, 0.3); + } + PlaceKind::Moon(p, m) => { + let planet = &system.planets[p]; + let moon = &planet.moons[m]; + what = format!("in orbit around {}, a moon of {}", moon.name, planet.name); + let radius = moon.kind.size * 0.6; + extent = 80.0; + body = Some(Body { + name: moon.name.clone(), + kind: moon.kind, + radius, + settled: moon.people.is_some(), + }); + backdrops.push(Backdrop { + name: planet.name.clone(), + kind: planet.kind, + pos: to_map(-moon.orbit.position(day)).normalised() * (36.0 + planet.kind.size), + radius: planet.kind.size * 1.1, + }); + let count = rocks_rng.random_range(1..=5); + ring(&mut rocks, &mut rocks_rng, count, radius + 6.0, radius + 22.0, 0.3); + } + PlaceKind::Belt => { + let belt = &system.belt; + what = format!("among the rocks of {}", belt.name); + // A denser field spreads wider too, so it's thicker without + // being packed solid. + extent = 140.0 * belt.density.sqrt().max(1.0); + // The whole field drifts one way, each rock a little differently. + let flow = Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(0.6..1.6); + let many = |n: f64| (n * belt.density).round() as i32; + for _ in 0..rocks_rng.random_range(many(70.0)..=many(100.0)) { + let pos = loop { + let pos = random_point(&mut rocks_rng, extent); + if pos.length() > 14.0 { + break pos; + } + }; + let wobble = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.4; + let (size, ore) = random_rock(&mut rocks_rng, belt.richness); + rocks.push(Rock::new(pos, flow + wobble, 0.0, size, ore)); + } + } + PlaceKind::Station(s) => { + let station = &system.stations[s]; + let planet = &system.planets[station.planet]; + let point = if station.leading { "L4" } else { "L5" }; + what = format!("by {}, at {}'s {point} point", station.name, planet.name); + extent = 90.0; + dock = Some(Dock { name: station.name.clone(), culture: station.culture }); + let toward_planet = planet.orbit.position(day) - here; + backdrops.push(Backdrop { + name: planet.name.clone(), + kind: planet.kind, + pos: to_map(toward_planet).normalised() * 40.0, + radius: planet.kind.size * 0.15, + }); + // Trojans: a loose swarm, sharing the point with the station. + for _ in 0..rocks_rng.random_range(25..=40) { + let pos = loop { + let pos = random_point(&mut rocks_rng, extent); + if pos.length() > 18.0 { + break pos; + } + }; + let vel = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.3; + let (size, ore) = random_rock(&mut rocks_rng, 0.45); + rocks.push(Rock::new(pos, vel, 0.0, size, ore)); + } + } + } + + let clear = + body.as_ref().map_or(0.0, |b| b.radius) + dock.as_ref().map_or(0.0, |_| DOCK_RADIUS); + let arrival = Vec2::new(0.0, (clear + 16.0).min(extent * 0.8)); + + let wrecks = system + .wrecks_at(place, day, departed, stripped) + .into_iter() + .map(|wreck| { + let mut rng = seed::rng(seed::derive(wreck.seed, "drift", place as u64)); + let pos = loop { + let pos = random_point(&mut rng, extent * 0.7); + if pos.length() > clear + 12.0 && pos.distance(arrival) > 10.0 { + break pos; + } + }; + LocalWreck { wreck, pos, heading: rng.random_range(0.0..TAU) } + }) + .collect(); + + Locale { place, name, what, extent, sun, body, dock, backdrops, rocks, wrecks, arrival } +} + +/// Rocks circling the middle of the map between two radii, all the same way. +fn ring(rocks: &mut Vec<Rock>, rng: &mut Rng, count: usize, inner: f64, outer: f64, ore: f64) { + let way = if rng.random_bool(0.5) { 1.0 } else { -1.0 }; + for _ in 0..count { + let r = rng.random_range(inner..outer); + let pos = Vec2::from_heading(rng.random_range(0.0..TAU)) * r; + // Further out goes round more slowly, as in a real orbit. + let circling = way * 4.0 / r.powf(1.5) * rng.random_range(0.9..1.1); + let (size, ore) = random_rock(rng, ore); + rocks.push(Rock::new(pos, Vec2::ZERO, circling, size, ore)); + } +} + +fn random_rock(rng: &mut Rng, ore_chance: f64) -> (Size, Option<&'static Ore>) { + let size = *[Size::Small, Size::Medium, Size::Medium, Size::Large].choose(rng).unwrap(); + let ore = rng.random_bool(ore_chance).then(|| ORES.choose_weighted(rng, |o| o.weight).unwrap()); + (size, ore) +} + +fn random_point(rng: &mut Rng, extent: f64) -> Vec2 { + Vec2::new(rng.random_range(-extent..extent), rng.random_range(-extent..extent)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::system; + + #[test] + fn every_place_makes_a_map() { + for seed in 0..40 { + let s = system::generate(seed); + for place in 0..s.places.len() { + let l = generate(&s, place, 12.5, None, &HashSet::new()); + assert!((l.sun.length() - 1.0).abs() < 1e-9); + assert!(l.arrival.x.abs() < l.extent && l.arrival.y.abs() < l.extent); + let solid = l.solid_radius().unwrap_or(0.0); + assert!(l.arrival.length() > solid + 5.0, "you arrive clear of what's there"); + for r in &l.rocks { + assert!(r.pos.length() > solid, "{}: a rock inside the middle", l.name); + } + let here = s.wrecks_on(12.5).iter().filter(|w| w.place == place).count(); + assert_eq!(l.wrecks.len(), here); + } + } + } + + #[test] + fn the_layout_stays_but_the_rocks_come_back() { + let s = system::generate(2); + let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + let (a, b, c) = { + let none = HashSet::new(); + ( + generate(&s, belt, 3.2, None, &none), + generate(&s, belt, 3.7, None, &none), + generate(&s, belt, 9.0, None, &none), + ) + }; + assert_eq!(a.rocks.len(), b.rocks.len(), "same day, same rocks"); + assert_eq!(a.rocks[0].pos, b.rocks[0].pos); + assert_ne!(a.rocks[0].pos, c.rocks[0].pos, "another day, new rocks"); + } + + #[test] + fn circling_rocks_keep_their_distance() { + let mut rock = Rock::new(Vec2::new(30.0, 0.0), Vec2::ZERO, 0.05, Size::Small, None); + for _ in 0..1000 { + rock.step(0.1, 100.0); + } + assert!((rock.pos.length() - 30.0).abs() < 1e-6); + } + + #[test] + fn drifting_rocks_wrap_round() { + let mut rock = Rock::new(Vec2::new(99.0, 0.0), Vec2::new(5.0, 0.0), 0.0, Size::Small, None); + rock.step(1.0, 100.0); + assert!((rock.pos.x + 96.0).abs() < 1e-9); + } +} diff --git a/src/space/mod.rs b/src/space/mod.rs new file mode 100644 index 0000000..74b092e --- /dev/null +++ b/src/space/mod.rs @@ -0,0 +1,154 @@ +//! Space: star systems with proper orbits, the places in them you can fly +//! around, and the rules of flight. +//! +//! There are two scales. A system is measured in AU and days: planets follow +//! Kepler orbits around the star, moons around their planets, and the +//! calendar moves them on. You never fly at that scale. Instead you travel +//! between places (a planet's orbit, a moon, a belt, a station), and each +//! place is a small local map you fly around in, measured in local units: +//! one unit is one terminal cell across and half a cell down (cells are about +//! twice as tall as they are wide), so circles come out round on screen. +//! Local maps use screen-style coordinates, with y pointing down. +//! +//! Above both is the galaxy: star systems in light-years, joined by lanes +//! you jump along (see `galaxy`). + +pub mod flight; +pub mod galaxy; +pub mod locale; +pub mod orbit; +pub mod survey; +pub mod system; + +use std::f64::consts::{PI, TAU}; +use std::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign}; + +use crate::seed; + +/// The star system a universe first started everyone in. It's one of the +/// four peoples' seats in its galaxy (see `galaxy`). +pub fn home_system(universe: u64) -> system::System { + system::generate(seed::derive(universe, "system", 0)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Vec2 { + pub x: f64, + pub y: f64, +} + +impl Vec2 { + pub const ZERO: Vec2 = Vec2::new(0.0, 0.0); + + pub const fn new(x: f64, y: f64) -> Vec2 { + Vec2 { x, y } + } + + /// On a local map, the unit vector pointing along `heading`: 0 is up the + /// screen, and headings increase clockwise. + pub fn from_heading(heading: f64) -> Vec2 { + Vec2::new(heading.sin(), -heading.cos()) + } + + /// The heading this vector points along, the inverse of `from_heading`. + pub fn heading(self) -> f64 { + self.x.atan2(-self.y).rem_euclid(TAU) + } + + pub fn rotated(self, angle: f64) -> Vec2 { + let (s, c) = angle.sin_cos(); + Vec2::new(self.x * c - self.y * s, self.x * s + self.y * c) + } + + pub fn length(self) -> f64 { + self.x.hypot(self.y) + } + + pub fn distance(self, other: Vec2) -> f64 { + (self - other).length() + } + + pub fn dot(self, other: Vec2) -> f64 { + self.x * other.x + self.y * other.y + } + + /// This vector at length 1, or zero if it has no length. + pub fn normalised(self) -> Vec2 { + let len = self.length(); + if len > 0.0 { self * (1.0 / len) } else { Vec2::ZERO } + } +} + +impl Add for Vec2 { + type Output = Vec2; + fn add(self, o: Vec2) -> Vec2 { + Vec2::new(self.x + o.x, self.y + o.y) + } +} + +impl Sub for Vec2 { + type Output = Vec2; + fn sub(self, o: Vec2) -> Vec2 { + Vec2::new(self.x - o.x, self.y - o.y) + } +} + +impl Mul<f64> for Vec2 { + type Output = Vec2; + fn mul(self, k: f64) -> Vec2 { + Vec2::new(self.x * k, self.y * k) + } +} + +impl Neg for Vec2 { + type Output = Vec2; + fn neg(self) -> Vec2 { + Vec2::new(-self.x, -self.y) + } +} + +impl AddAssign for Vec2 { + fn add_assign(&mut self, o: Vec2) { + *self = *self + o; + } +} + +impl SubAssign for Vec2 { + fn sub_assign(&mut self, o: Vec2) { + *self = *self - o; + } +} + +/// The signed angle to turn through to get from `from` to `to`, the short +/// way round: between -π and π. +pub fn turn_between(from: f64, to: f64) -> f64 { + (to - from + PI).rem_euclid(TAU) - PI +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headings_round_trip() { + for i in 0..16 { + let h = TAU * f64::from(i) / 16.0; + assert!((Vec2::from_heading(h).heading() - h).abs() < 1e-9); + } + let up = Vec2::from_heading(0.0); + assert!(up.x.abs() < 1e-9 && (up.y + 1.0).abs() < 1e-9, "heading 0 points up the screen"); + assert!((Vec2::from_heading(TAU / 4.0).x - 1.0).abs() < 1e-9, "a quarter turn is right"); + } + + #[test] + fn turning_takes_the_short_way() { + assert!((turn_between(0.1, TAU - 0.1) + 0.2).abs() < 1e-9); + assert!((turn_between(TAU - 0.1, 0.1) - 0.2).abs() < 1e-9); + } + + #[test] + fn rotation_is_anticlockwise() { + let v = Vec2::new(1.0, 0.0).rotated(PI / 2.0); + assert!(v.x.abs() < 1e-9 && (v.y - 1.0).abs() < 1e-9); + } +} diff --git a/src/space/orbit.rs b/src/space/orbit.rs new file mode 100644 index 0000000..0bcea5e --- /dev/null +++ b/src/space/orbit.rs @@ -0,0 +1,164 @@ +//! Kepler orbits, and how long trips between them take. +//! +//! Distances are in AU, times in days, masses in suns. Orbits lie flat in +//! the plane of the system (no inclination) and run anticlockwise. + +use std::f64::consts::TAU; + +use super::Vec2; + +/// Days in a year for a body 1 AU from a star of one solar mass. +const YEAR: f64 = 365.25; +/// An Earth mass, in suns. +pub const EARTH_MASS: f64 = 3.003e-6; + +#[derive(Clone, Copy, Debug)] +pub struct Orbit { + /// Semi-major axis. + pub a: f64, + /// Eccentricity: 0 is a circle. + pub e: f64, + /// Angle from the x axis to the closest point of the orbit. + pub periapsis: f64, + /// Where along the orbit the body is at day 0, as a mean anomaly. + pub mean_at_epoch: f64, + /// Days per revolution. + pub period: f64, +} + +impl Orbit { + /// An orbit around a body of `parent_mass` suns, with its period from + /// Kepler's third law. + pub fn new(a: f64, e: f64, periapsis: f64, mean_at_epoch: f64, parent_mass: f64) -> Orbit { + let period = YEAR * (a.powi(3) / parent_mass).sqrt(); + Orbit { a, e, periapsis, mean_at_epoch, period } + } + + /// Position relative to the parent on `day`. + pub fn position(&self, day: f64) -> Vec2 { + let mean = self.mean_at_epoch + TAU * day / self.period; + self.at_eccentric_anomaly(eccentric_anomaly(mean, self.e)) + } + + /// `n` points evenly spaced around the ellipse, for drawing it. + pub fn outline(&self, n: usize) -> impl Iterator<Item = Vec2> + '_ { + (0..n).map(move |i| self.at_eccentric_anomaly(TAU * i as f64 / n as f64)) + } + + /// Closest and farthest distances from the parent. + pub fn periapsis_distance(&self) -> f64 { + self.a * (1.0 - self.e) + } + + pub fn apoapsis_distance(&self) -> f64 { + self.a * (1.0 + self.e) + } + + fn at_eccentric_anomaly(&self, ea: f64) -> Vec2 { + let x = self.a * (ea.cos() - self.e); + let y = self.a * (1.0 - self.e * self.e).sqrt() * ea.sin(); + Vec2::new(x, y).rotated(self.periapsis) + } +} + +/// Solves Kepler's equation, M = E - e sin E, for E by Newton's method. +fn eccentric_anomaly(mean: f64, e: f64) -> f64 { + let m = mean.rem_euclid(TAU); + let mut ea = if e > 0.8 { std::f64::consts::PI } else { m }; + for _ in 0..12 { + let step = (ea - e * ea.sin() - m) / (1.0 - e * ea.cos()); + ea -= step; + if step.abs() < 1e-12 { + break; + } + } + ea +} + +/// Days a trip of 1 AU takes. Trips are skewed well away from realism, so +/// that a far planet costs only a few days more than a near one: days grow +/// with the cube root of the distance. A moon is hours away, most planets +/// two to five days, and the far edge of the widest systems about ten. +pub const AU_TRIP: f64 = 2.25; + +/// The shortest trip, however close the destination: undocking, getting +/// clear and settling into the new place. +pub const MIN_TRIP: f64 = 0.25; + +pub fn trip_days(distance: f64) -> f64 { + (AU_TRIP * distance.cbrt()).max(MIN_TRIP) +} + +/// How many days a trip from `from` (where you are now, on `day`) to a +/// destination that moves along `destination(day)` takes. Aims for where +/// the destination will be on arrival, not where it is now. +pub fn plan_trip(from: Vec2, day: f64, destination: impl Fn(f64) -> Vec2) -> f64 { + let mut days = trip_days(from.distance(destination(day))); + for _ in 0..8 { + days = trip_days(from.distance(destination(day + days))); + } + days +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keplers_third_law() { + let earthlike = Orbit::new(1.0, 0.0, 0.0, 0.0, 1.0); + assert!((earthlike.period - 365.25).abs() < 1e-9); + let far = Orbit::new(4.0, 0.0, 0.0, 0.0, 1.0); + assert!( + (far.period - 8.0 * 365.25).abs() < 1e-9, + "four times out takes eight times as long" + ); + } + + #[test] + fn orbits_come_round_again() { + let o = Orbit::new(2.3, 0.2, 1.0, 0.4, 0.8); + let (p, q) = (o.position(17.0), o.position(17.0 + o.period)); + assert!(p.distance(q) < 1e-9); + } + + #[test] + fn ellipses_swing_between_periapsis_and_apoapsis() { + let o = Orbit::new(2.0, 0.25, 0.7, 0.0, 1.0); + let at_start = o.position(0.0).length(); + assert!((at_start - o.periapsis_distance()).abs() < 1e-9, "mean anomaly 0 is periapsis"); + let half = o.position(o.period / 2.0).length(); + assert!((half - o.apoapsis_distance()).abs() < 1e-9); + let p = o.position(0.0); + let angle = p.y.atan2(p.x); + assert!((angle - 0.7).abs() < 1e-9, "periapsis points where it says"); + } + + #[test] + fn keplers_second_law_bodies_hurry_near_periapsis() { + let o = Orbit::new(1.0, 0.3, 0.0, 0.0, 1.0); + let near = o.position(0.0).distance(o.position(1.0)); + let far = o.position(o.period / 2.0).distance(o.position(o.period / 2.0 + 1.0)); + assert!(near > far * 1.5); + } + + #[test] + fn trips_aim_ahead_of_a_moving_target() { + let target = Orbit::new(1.0, 0.0, 0.0, 0.0, 1.0); + let from = Vec2::new(5.0, 0.0); + let days = plan_trip(from, 0.0, |d| target.position(d)); + let arrival = target.position(days); + assert!((trip_days(from.distance(arrival)) - days).abs() < 1e-6); + } + + #[test] + fn trips_take_days_not_weeks() { + assert!(trip_days(0.003) < 0.5, "a moon is hours away"); + assert!((2.0..3.0).contains(&trip_days(1.0))); + assert!(trip_days(10.0) < 5.0); + assert!(trip_days(90.0) < 11.0, "even the far edge of a wide system"); + assert_eq!(trip_days(1e-6), MIN_TRIP); + // Far places cost more, but not much more. + assert!((trip_days(27.0) / trip_days(1.0) - 3.0).abs() < 1e-9); + } +} diff --git a/src/space/survey.rs b/src/space/survey.rs new file mode 100644 index 0000000..e3c91fe --- /dev/null +++ b/src/space/survey.rs @@ -0,0 +1,365 @@ +//! What a ship's survey says about a place: how hot it is and how that +//! swings over its year, how long its days are, how hard it pulls, what its +//! air is like, what hangs in its sky, and how many people live there. +//! None of it changes how the game plays. It's there so each place feels +//! like somewhere. +//! +//! Temperatures are worked out, not made up: the starlight a body gets at +//! its distance from the star (which changes along an eccentric orbit), +//! less what it reflects, plus what its air traps or its insides give off. +//! Moons share their planet's distance. Everything else is drawn from a +//! hash of the place, so it's the same every visit and system generation +//! (and every universe already made) is left alone. + +use std::f64::consts::TAU; + +use super::orbit::{EARTH_MASS, Orbit}; +use super::system::{Climate, Landing, PlaceKind, StarKind, System, SystemKind}; +use crate::names::Culture; +use crate::scenes::sky::hash01; +use crate::seed; + +/// How warm a bare, black, quickly turning rock gets 1 AU from a star of +/// one sun, in kelvin. +const WARMTH_AT_1AU: f64 = 278.6; +/// Water freezes at this many kelvin. +pub const FREEZING: f64 = 273.15; +/// How wide the Sun looks from 1 AU, in degrees. +const SUN_AT_1AU: f64 = 0.533; +const EARTH_RADIUS_KM: f64 = 6371.0; +const AU_KM: f64 = 149.6e6; +/// Rocks in the belt: dark, and no air to keep them warm. +const BELT_CLIMATE: Climate = Climate { albedo: 0.1, warmth: 0.0, air: &[], breathable: false }; + +pub struct Survey { + /// At the surface, or a giant's cloud tops. None aboard a station, + /// where it's whatever they set it to. + pub temperature: Option<Temperature>, + /// How long a day lasts, or how fast a station spins. + pub day: Option<String>, + /// What its tilt does to its year, if it has days to speak of. + pub seasons: Option<&'static str>, + /// In g: at the surface, at a giant's cloud tops, or from a station's spin. + pub gravity: Option<f64>, + /// What the air is like, and whether you can breathe it. None if + /// there isn't any. + pub air: Option<(&'static str, bool)>, + /// What you'd see overhead. + pub sky: String, + /// How many live there, if anyone does. + pub population: Option<u64>, +} + +/// In kelvin. +#[derive(Clone, Copy, Debug)] +pub struct Temperature { + pub now: f64, + /// At the far end of its orbit. + pub coldest: f64, + /// At the near end. + pub hottest: f64, +} + +impl Temperature { + fn along(orbit: &Orbit, day: f64, star: &StarKind, climate: &Climate) -> Temperature { + let at = |distance: f64| { + WARMTH_AT_1AU * (star.luminosity * (1.0 - climate.albedo)).powf(0.25) / distance.sqrt() + + climate.warmth + }; + Temperature { + now: at(orbit.position(day).length()), + coldest: at(orbit.apoapsis_distance()), + hottest: at(orbit.periapsis_distance()), + } + } + + /// One word for how it feels, going by the temperature now. + pub fn word(self) -> &'static str { + match self.now - FREEZING { + c if c < -150.0 => "deep-frozen", + c if c < -60.0 => "frozen", + c if c < -20.0 => "bitter", + c if c < 5.0 => "cold", + c if c < 15.0 => "cool", + c if c < 28.0 => "temperate", + c if c < 45.0 => "hot", + c if c < 120.0 => "scorching", + c if c < 400.0 => "searing", + _ => "molten", + } + } +} + +/// Kelvin to whole degrees Celsius, for showing. +pub fn celsius(kelvin: f64) -> i64 { + (kelvin - FREEZING).round() as i64 +} + +pub fn survey(s: &System, place: usize, day: f64) -> Survey { + let key = seed::derive(s.seed, "survey", place as u64); + let h = |i: i64| hash01(key, i, 0); + let star = s.star; + let landing = s.landing(place); + match s.places[place].kind { + PlaceKind::Planet(p) => { + let planet = &s.planets[p]; + let kind = planet.kind; + let temperature = Temperature::along(&planet.orbit, day, star, &kind.climate); + // Close to a small star, tides stop a world turning. + let locked = !kind.giant && planet.orbit.a < 0.4 * star.mass; + let (day_length, seasons) = if locked { + ("locked: one face in endless day".to_string(), None) + } else { + let hours = if kind.giant { + lerp(9.0, 18.0, h(1)) + } else if h(2) < 0.08 { + 24.0 * log_lerp(5.0, 120.0, h(1)) + } else { + log_lerp(10.0, 80.0, h(1)) + }; + (duration(hours), Some(seasons(h(3), h(4)))) + }; + let earths = planet.mass / EARTH_MASS; + let radius = radius(earths, kind.giant); + let moons = match planet.moons.len() { + 0 => String::new(), + 1 => " and a moon".to_string(), + n => format!(" and {n} moons"), + }; + Survey { + temperature: Some(temperature), + day: Some(day_length), + seasons, + gravity: Some(earths / (radius * radius)), + air: air(&kind.climate, temperature, h(5)), + sky: format!("{}{moons}", sun(star, planet.orbit.position(day).length())), + population: population(landing, s.kind, p == s.home, false, h(6)), + } + } + PlaceKind::Moon(p, m) => { + let planet = &s.planets[p]; + let moon = &planet.moons[m]; + let temperature = Temperature::along(&planet.orbit, day, star, &moon.kind.climate); + let earths = log_lerp(0.004, 0.12, h(1)); + // How wide its planet looks, from down on the surface. + let planet_km = radius(planet.mass / EARTH_MASS, planet.kind.giant) * EARTH_RADIUS_KM; + let across = 2.0 * (planet_km / (moon.orbit.a * AU_KM)).atan().to_degrees(); + let size = if across < 1.0 { format!("{across:.1}") } else { format!("{across:.0}") }; + let looms = if across >= 3.0 { "looms" } else { "hangs" }; + Survey { + temperature: Some(temperature), + day: Some(format!( + "{}, always facing {}", + duration(24.0 * moon.orbit.period), + planet.name + )), + seasons: None, + gravity: Some(earths.powf(0.46)), + air: air(&moon.kind.climate, temperature, h(5)), + sky: format!( + "{} {looms} {size}° wide; {}", + planet.name, + sun(star, planet.orbit.position(day).length()) + ), + population: population(landing, s.kind, false, true, h(6)), + } + } + PlaceKind::Station(k) => { + let station = &s.stations[k]; + let planet = &s.planets[station.planet]; + // Spun for gravity: the bigger the wheel, the slower it turns. + let g = lerp(0.35, 1.0, h(1)); + let wheel = lerp(150.0, 900.0, h(2)); + let seconds = TAU * (wheel / (g * 9.81)).sqrt(); + Survey { + temperature: None, + day: Some(format!("turns once every {seconds:.0} seconds")), + seasons: None, + gravity: Some(g), + air: Some((station_air(station.culture), true)), + sky: format!( + "{}, and {} a bright star", + sun(star, planet.orbit.position(day).length()), + planet.name + ), + population: population(landing, s.kind, false, false, h(6)), + } + } + PlaceKind::Belt => { + let region = &s.belt.region; + Survey { + temperature: Some(Temperature::along(region, day, star, &BELT_CLIMATE)), + day: None, + seasons: None, + gravity: None, + air: None, + sky: format!("{}, and rocks tumbling all round", sun(star, region.a)), + population: None, + } + } + } +} + +/// In Earth radii, for a body of `earths` Earth masses: rocky worlds grow +/// slowly with mass, and giants stop growing at about Jupiter's size. +fn radius(earths: f64, giant: bool) -> f64 { + if giant { earths.powf(0.48).min(11.3) } else { earths.powf(0.27) } +} + +fn air(climate: &Climate, t: Temperature, h: f64) -> Option<(&'static str, bool)> { + if climate.air.is_empty() { + return None; + } + let what = climate.air[(h * climate.air.len() as f64) as usize % climate.air.len()]; + let bearable = (FREEZING - 40.0..=FREEZING + 55.0).contains(&t.now); + Some((what, climate.breathable && bearable)) +} + +fn station_air(culture: Culture) -> &'static str { + match culture { + Culture::Qaari => "warm and dry, scented with spice-tea", + Culture::Cant => "recycled; hot solder and incense", + Culture::Houses => "perfumed, and filtered twice over", + Culture::Drift => "stale, and patched like the hulls", + } +} + +/// The star as it looks from `distance` AU. +fn sun(star: &StarKind, distance: f64) -> String { + let colour = star.name.trim_end_matches(" dwarf").trim_end_matches(" star"); + // Near enough for a star in the prime of its life. + let radius = star.mass.powf(0.8); + let degrees = SUN_AT_1AU * radius / distance; + let size = match degrees { + d if d < 0.12 => return format!("a {colour} sun, shrunk to a point"), + d if d < 0.35 => "small ", + d if d < 0.9 => "", + d if d < 2.5 => "broad ", + d if d < 6.0 => "vast ", + _ => "sky-filling ", + }; + format!("a {size}{colour} sun") +} + +/// How much its tilt changes its year. Most worlds lean a little; a few +/// roll round their star on their side. +fn seasons(h: f64, h2: f64) -> &'static str { + let tilt = if h < 0.05 { 80.0 + 20.0 * h2 } else { 40.0 * h2 * h2 }; + match tilt { + t if t < 4.0 => "no seasons", + t if t < 15.0 => "mild seasons", + t if t < 30.0 => "strong seasons", + t if t < 60.0 => "harsh seasons", + _ => "spins on its side", + } +} + +/// How many people live somewhere: billions on a seat's homeworld, far +/// fewer out on the frontier or among the wrecks. +fn population(landing: Landing, kind: SystemKind, main: bool, moon: bool, h: f64) -> Option<u64> { + let (low, high) = match landing { + Landing::Capital(_) if main => match kind { + SystemKind::Seat => (6e8, 4e9), + SystemKind::Settled => (4e7, 6e8), + SystemKind::Rubble => (3e6, 6e7), + _ => (2e5, 8e6), + }, + Landing::Capital(_) if moon => (2e5, 2e7), + Landing::Capital(_) => (5e6, 3e8), + Landing::Aloft(_) => (1e6, 4e7), + Landing::Market(_) => (3e3, 8e4), + Landing::Wilds | Landing::Nowhere => return None, + }; + // Away from the settled heart of things, everywhere is emptier. + let thin = matches!(kind, SystemKind::Frontier | SystemKind::Graveyard) && !main; + let (low, high) = if thin { (low * 0.1, high * 0.1) } else { (low, high) }; + // To two figures: nobody counts heads more closely than that. + let n = log_lerp(low, high, h); + let unit = 10f64.powi(n.log10().floor() as i32 - 1); + Some(((n / unit).round() * unit) as u64) +} + +/// "26 hours", "3.5 days", "41 days". +pub fn duration(hours: f64) -> String { + let days = hours / 24.0; + if hours < 72.0 { + format!("{hours:.0} hours") + } else if days < 10.0 { + format!("{days:.1} days") + } else { + format!("{days:.0} days") + } +} + +fn lerp(low: f64, high: f64, k: f64) -> f64 { + low + (high - low) * k +} + +/// From `low` to `high`, evenly in orders of magnitude. +fn log_lerp(low: f64, high: f64, k: f64) -> f64 { + (low.ln() + (high.ln() - low.ln()) * k).exp() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::system::generate; + + #[test] + fn surveys_are_the_same_every_time() { + let s = generate(4); + for place in 0..s.places.len() { + let (a, b) = (survey(&s, place, 30.0), survey(&s, place, 30.0)); + assert_eq!((a.day, a.sky, a.population), (b.day, b.sky, b.population)); + } + } + + #[test] + fn temperatures_swing_between_the_ends_of_the_orbit() { + for seed in 0..60 { + let s = generate(seed); + for place in 0..s.places.len() { + for day in [0.0, 100.0, 1234.5] { + let Some(t) = survey(&s, place, day).temperature else { continue }; + assert!(t.coldest <= t.now + 1e-9 && t.now <= t.hottest + 1e-9); + assert!(t.coldest > 0.0, "seed {seed}: below absolute zero"); + } + } + } + } + + #[test] + fn a_wet_world_in_the_middle_of_the_habitable_zone_is_mild() { + let star = &crate::space::system::STARS[0]; + let hz = star.luminosity.sqrt(); + let orbit = Orbit::new(hz, 0.0, 0.0, 0.0, star.mass); + let ocean = Climate { albedo: 0.3, warmth: 33.0, air: &["wet"], breathable: true }; + let t = Temperature::along(&orbit, 0.0, star, &ocean); + assert_eq!(celsius(t.now), 15, "about Earth's average"); + assert_eq!(t.word(), "cool"); + assert_eq!(air(&ocean, t, 0.0), Some(("wet", true))); + } + + #[test] + fn only_settled_places_have_people() { + for seed in 0..40 { + let s = generate(seed); + for place in 0..s.places.len() { + let people = survey(&s, place, 0.0).population; + assert_eq!( + people.is_some(), + !matches!(s.landing(place), Landing::Wilds | Landing::Nowhere) + ); + } + let home = survey(&s, s.home_place(), 0.0).population.unwrap(); + assert!(home >= 600_000_000, "seed {seed}: a homeworld of {home}"); + } + } + + #[test] + fn durations_read_naturally() { + assert_eq!(duration(26.2), "26 hours"); + assert_eq!(duration(84.0), "3.5 days"); + assert_eq!(duration(24.0 * 41.2), "41 days"); + } +} 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"); + } +} |