summaryrefslogtreecommitdiff
path: root/src/space
diff options
context:
space:
mode:
Diffstat (limited to 'src/space')
-rw-r--r--src/space/flight.rs434
-rw-r--r--src/space/galaxy.rs573
-rw-r--r--src/space/locale.rs406
-rw-r--r--src/space/mod.rs154
-rw-r--r--src/space/orbit.rs164
-rw-r--r--src/space/survey.rs365
-rw-r--r--src/space/system.rs1215
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");
+ }
+}