//! The system map: the star, every orbit, where everything is today, and a //! list of places to travel to with how long each trip takes. //! //! Distances from the star are drawn on a square-root scale, so the inner //! planets aren't crushed into the middle and the outer ones still fit. //! Moons are too close to their planets to see at any scale that shows the //! whole system, so they're drawn just off their planets, in the right //! direction. //! //! Choosing a place shows where it will be when you get there: trips aim //! for that, not for where it is now, and a survey of what it's like. //! //! Every place is marked with what you'll find if you land or dock there: //! a capital city (⌂), a station that only trades (¤), or neither (·). //! //! `g` swaps this for the star map, to jump to another system. use std::f64::consts::TAU; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::symbols::Marker; use ratatui::text::{Line, Span}; use ratatui::widgets::canvas::{Canvas, Context, Points}; use ratatui::widgets::{Block, Paragraph}; use super::galaxy_map::GalaxyMap; use super::keys; use super::sky; use super::{Scene, Transition}; use crate::app::Ctx; use crate::names::Culture; use crate::space::Vec2; use crate::space::orbit; use crate::space::survey::{self, FREEZING, Survey, Temperature}; use crate::space::system::{Landing, PlaceKind, Rgb, System, Wreck}; use crate::town; const PANEL_WIDTH: u16 = 46; /// Rows for what's known about the chosen place. const ABOUT_HEIGHT: u16 = 20; /// The labels down the left of the survey: "temp", "air". const LABEL_WIDTH: usize = 7; const ACCENT: Color = Color::Rgb(240, 200, 110); const DIM: Color = Color::Rgb(110, 114, 132); const ORBIT: Color = Color::Rgb(58, 62, 80); const CAPITAL: Color = Color::Rgb(130, 214, 140); const MARKET: Color = Color::Rgb(110, 196, 226); const WRECK: Color = Color::Rgb(255, 90, 80); /// A beacon that's failing, or will have failed by the time you get there. pub(crate) const FAINT_WRECK: Color = Color::Rgb(150, 76, 70); /// A beacon with this few days left is failing. pub(crate) const FAILING: f64 = 5.0; const SUIT_UP: Color = Color::Rgb(240, 150, 90); pub struct SystemMap { selected: usize, } impl SystemMap { pub fn new(ctx: &Ctx) -> SystemMap { SystemMap { selected: ctx.location } } /// Days to get from where you are to `place`. fn trip(ctx: &Ctx, place: usize) -> f64 { let s = &ctx.system; let from = s.position(ctx.location, ctx.day); orbit::plan_trip(from, ctx.day, |d| s.position(place, d)) } } impl Scene for SystemMap { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { let n = ctx.system.places.len(); match key.code { KeyCode::Up | KeyCode::Char('k' | 'w') => self.selected = (self.selected + n - 1) % n, KeyCode::Down | KeyCode::Char('j' | 's') => self.selected = (self.selected + 1) % n, KeyCode::Enter | KeyCode::Char('t') => { if self.selected != ctx.location { let days = Self::trip(ctx, self.selected); ctx.travel(self.selected, days); } return Transition::Pop; } KeyCode::Char('.') => ctx.day += 1.0, KeyCode::Char('g') => return Transition::Replace(vec![Box::new(GalaxyMap::new(ctx))]), KeyCode::Esc | KeyCode::Char('m' | 'q') => return Transition::Pop, _ => {} } Transition::None } fn render(&self, frame: &mut Frame, ctx: &Ctx) { let area = frame.area(); let (map, panel) = if area.width >= 100 { let [map, panel] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) .areas(area); (Some(map), panel) } else { (None, area) }; if let Some(map) = map { self.draw_orrery(frame, map, ctx); } self.draw_panel(frame, panel, ctx); } } /// Positions in AU to canvas points, on a square-root scale out to `extent`. struct Scale { radius: f64, extent: f64, } impl Scale { fn point(&self, p: Vec2) -> (f64, f64) { let d = p.length(); if d == 0.0 { return (0.0, 0.0); } let r = self.radius * (d / self.extent).sqrt(); (p.x / d * r, p.y / d * r) } } impl SystemMap { fn draw_orrery(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { let s = &ctx.system; let day = ctx.day; // One unit per braille dot: two across a cell, four down, and dots // are about square. let half_w = f64::from(area.width); let half_h = 2.0 * f64::from(area.height); let scale = Scale { radius: half_w.min(half_h) * 0.92, extent: s.extent() }; let trip = (self.selected != ctx.location).then(|| Self::trip(ctx, self.selected)); let canvas = Canvas::default() .marker(Marker::Braille) .background_color(Color::Reset) .x_bounds([-half_w, half_w]) .y_bounds([-half_h, half_h]) .paint(|c| { draw_orbits(c, s, &scale); c.layer(); if let Some(days) = trip { let from = scale.point(s.position(ctx.location, day)); let to = scale.point(s.position(self.selected, day + days)); let steps = ((to.0 - from.0).hypot(to.1 - from.1) / 3.0) as usize; let dots: Vec<(f64, f64)> = (0..=steps) .map(|i| { let t = i as f64 / steps.max(1) as f64; (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t) }) .collect(); c.draw(&Points { coords: &dots, color: ACCENT }); c.print(to.0, to.1, Span::raw("◌").fg(ACCENT)); } self.draw_bodies(c, ctx, &scale); }); frame.render_widget(canvas, area); } fn draw_bodies(&self, c: &mut Context, ctx: &Ctx, scale: &Scale) { let s = &ctx.system; let day = ctx.day; c.print(0.0, 0.0, Span::raw("☼").fg(sky::rgb(s.star.rgb)).bold()); let mut labels = Vec::new(); for (i, place) in s.places.iter().enumerate() { let here = i == ctx.location; let chosen = i == self.selected; let (x, y) = match place.kind { PlaceKind::Planet(p) => { let (x, y) = scale.point(s.planets[p].orbit.position(day)); let glyph = if here { "@" } else { "●" }; let colour = if here { Color::White } else { sky::rgb(s.planets[p].kind.rgb) }; c.print(x, y, Span::raw(glyph).fg(colour).bold()); if let Landing::Capital(_) = s.landing(i) { c.print(x + 2.0, y, Span::raw("⌂").fg(CAPITAL)); } (x, y) } PlaceKind::Moon(p, m) => { let (px, py) = scale.point(s.planets[p].orbit.position(day)); let moon = &s.planets[p].moons[m]; let dir = moon.orbit.position(day).normalised(); let r = 4.0 + 2.5 * m as f64; let (x, y) = (px + dir.x * r, py + dir.y * r); let colour = if here || chosen { Color::White } else { sky::rgb(moon.kind.rgb) }; if here { c.print(x, y, Span::raw("@").fg(Color::White).bold()); } else if let Landing::Capital(_) = s.landing(i) { c.print( x, y, Span::raw("⌂").fg(if chosen { Color::White } else { CAPITAL }), ); } else { c.draw(&Points { coords: &[(x, y)], color: colour }); } (x, y) } PlaceKind::Belt => { let (x, y) = scale.point(s.belt.region.position(day)); if here { c.print(x, y, Span::raw("@").fg(Color::White).bold()); } else if chosen { c.print(x, y, Span::raw("+").fg(ACCENT)); } (x, y) } PlaceKind::Station(k) => { let (x, y) = scale.point(s.position(i, day)); let (_, colour) = sky::station_art(s.stations[k].culture); let glyph = if here { "@" } else { "#" }; c.print( x, y, Span::raw(glyph).fg(if here { Color::White } else { sky::rgb(colour) }), ); (x, y) } }; if chosen || here { let style = if chosen { Style::new().fg(ACCENT) } else { Style::new().fg(Color::White) }; labels.push((x + 4.0, y, Span::styled(format!(" {}", place.name), style))); } } // Names last, so nothing nearby is drawn over them. for (x, y, label) in labels { c.print(x, y, label); } } fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { let s = &ctx.system; let block = Block::bordered() .title(format!(" the {} system · day {:.1} ", s.name, ctx.day)) .title_bottom( Line::from(format!( " {} of {} · fuel {}/{} ", capitalise(s.kind.name()), s.culture.name(), ctx.hold.fuel, ctx.hold.fuel_capacity )) .fg(DIM), ) .title_style(Style::new().fg(ACCENT).bold()) .border_style(Style::new().fg(DIM)); let inner = block.inner(area); frame.render_widget(block, area); let hints = keys::packed( &[ ("↑↓", "choose"), ("enter", "travel"), (".", "wait a day"), ("g", "star map"), ("esc", "back"), ], inner.width, ); let [list, about, keys] = Layout::vertical([ Constraint::Min(3), Constraint::Length(ABOUT_HEIGHT), Constraint::Length(1 + hints.len() as u16), ]) .areas(inner); // Scroll to keep the choice in view. let rows = usize::from(list.height); let first = self .selected .saturating_sub(rows.saturating_sub(1) / 2) .min(s.places.len().saturating_sub(rows)); let wrecks = ctx.wrecks(); let lines: Vec = s .places .iter() .enumerate() .skip(first) .take(rows) .map(|(i, place)| { let indent = match place.kind { PlaceKind::Moon(..) | PlaceKind::Station(_) => " ", _ => "", }; let (mark, colour) = mark(s.landing(i)); let days = if i == ctx.location { 0.0 } else { Self::trip(ctx, i) }; let trip = if i == ctx.location { "you're here".to_string() } else { format!("{days:.1} d") }; // Bright if there's a wreck with time to spare; faint if all // that's there are beacons on their last legs, which wait // only if you go straight there. let here = wrecks.iter().filter(|w| w.place == i); let worth = here.clone().any(|w| w.leaves - ctx.day > FAILING); let beacon = match (here.count(), worth) { (0, _) => Span::raw(" "), (_, true) => Span::raw("!").fg(WRECK), (_, false) => Span::raw("!").fg(FAINT_WRECK), }; let name_width = usize::from(list.width).saturating_sub(16 + indent.len()); let name: String = place.name.chars().take(name_width).collect(); let mut spans = vec![ Span::raw(format!(" {mark} ")).fg(colour), Span::raw(format!("{indent}{name:12}")).fg(DIM), ]; if i == self.selected { for span in &mut spans { span.style = span.style.add_modifier(Modifier::REVERSED); } } Line::from(spans) }) .collect(); frame.render_widget(Paragraph::new(lines), list); frame.render_widget( // Wrapped already, so that wrapped lines stay indented. Paragraph::new(self.about(ctx, usize::from(about.width))), about, ); let legend = Line::from(vec![ Span::raw("⌂").fg(CAPITAL), Span::raw(" capital ").fg(DIM), Span::raw("¤").fg(MARKET), Span::raw(" trade only ").fg(DIM), Span::raw("·").fg(DIM), Span::raw(" neither ").fg(DIM), Span::raw("!").fg(WRECK), Span::raw(" wreck").fg(DIM), ]); let mut lines = vec![legend]; lines.extend(hints); frame.render_widget(Paragraph::new(lines), keys); } /// What's known about the chosen place: what it is, what you'll find if /// you land, the trip there, and a survey of what it's like. fn about(&self, ctx: &Ctx, width: usize) -> Vec> { let s = &ctx.system; let i = self.selected; let place = &s.places[i]; let who = |people: Option, home: bool| match people { Some(c) if home && s.kind != crate::space::system::SystemKind::Seat => { format!("chief world of {}", c.name()) } Some(c) if home => format!("homeworld of {}", c.name()), Some(c) => format!("settled by {}", c.name()), None => "uninhabited".to_string(), }; let (what, orbit_line) = match place.kind { PlaceKind::Planet(p) => { let planet = &s.planets[p]; ( format!("{} · {}", planet.kind.name, who(planet.people, p == s.home)), format!( "{:.2} AU from the star; a year of {:.0} days", planet.orbit.a, planet.orbit.period ), ) } PlaceKind::Moon(p, m) => { let moon = &s.planets[p].moons[m]; ( format!("{} · {}", moon.kind.name, who(moon.people, false)), format!( "a moon of {}, going round every {:.1} days", s.planets[p].name, moon.orbit.period ), ) } PlaceKind::Belt => ( "an asteroid belt: good mining".to_string(), format!("from {:.2} to {:.2} AU from the star", s.belt.inner, s.belt.outer), ), PlaceKind::Station(k) => { let st = &s.stations[k]; let point = if st.leading { "L4" } else { "L5" }; ( format!("a {} station", st.culture.adjective()), format!( "at {}'s {point} point, sixty degrees {} it", s.planets[st.planet].name, if st.leading { "ahead of" } else { "behind" } ), ) } }; let prose = |text: String, style: Style| wrap(Vec::new(), &[(text, style)], width); let mut lines = vec![Line::from(place.name.clone()).fg(ACCENT).bold()]; lines.extend(prose(what, Style::new())); lines.extend(landing_line(s, i, width)); if i != ctx.location { let days = Self::trip(ctx, i); let now = s.position(ctx.location, ctx.day); let there = s.position(i, ctx.day + days); lines.extend(prose( format!( "{:.2} AU away by the time you arrive: {:.1} days, on day {:.1}", now.distance(there), days, ctx.day + days ), Style::new(), )); } let arrival = if i == ctx.location { ctx.day } else { ctx.day + Self::trip(ctx, i) }; for w in ctx.wrecks().iter().filter(|w| w.place == i) { let found = if w.calling(ctx.day) { format!( "A distress beacon: {}, a {}. {}", w.name, w.description, beacon_age(w, ctx.day) ) } else { format!( "A wreck: {}, a {}. Its beacon failed on your way here, but you came by \ its last fix.", w.name, w.description ) }; let mut phrases = vec![(found, Style::new().fg(Color::Rgb(255, 120, 100)))]; if let Some(warning) = beacon_warning(w, ctx.day, arrival) { phrases.push((format!(" {warning}"), Style::new().fg(FAINT_WRECK))); } lines.extend(wrap(Vec::new(), &phrases, width)); } lines.push(Line::default()); let station = matches!(place.kind, PlaceKind::Station(_)); let giant = match place.kind { PlaceKind::Planet(p) => s.planets[p].kind.giant, _ => false, }; lines.extend(facts(&survey::survey(s, i, arrival), station, giant, width)); lines.push(Line::default()); lines.extend(prose(orbit_line, Style::new().fg(DIM))); lines } } /// How long a wreck's beacon has been calling: "Calling for 12 days." pub(crate) fn beacon_age(w: &Wreck, day: f64) -> String { match (day - w.arrived).floor() { ..1.0 => "It started calling today.".into(), 1.0 => "Calling since yesterday.".into(), n => format!("Calling for {n:.0} days."), } } /// Whether a beacon is failing, or will fail before you `arrive`. A wreck /// whose beacon has failed already is one you came by its last fix: there's /// nothing to warn of. pub(crate) fn beacon_warning(w: &Wreck, day: f64, arrive: f64) -> Option { let left = w.leaves - day; if left <= 0.0 { None } else if w.leaves <= arrive { Some( "It'll fail on the way, but go straight there and you'll find it by its last fix." .into(), ) } else if left <= FAILING { Some(match left.floor() { ..1.0 => "Failing: hours left.".into(), 1.0 => "Failing: a day left, maybe two.".into(), n => format!("Failing: {n:.0} days left, maybe less."), }) } else { None } } /// How a place is marked in the list: what you'll find if you land. fn mark(landing: Landing) -> (&'static str, Color) { match landing { Landing::Capital(_) => ("⌂", CAPITAL), Landing::Market(_) => ("¤", MARKET), Landing::Aloft(_) | Landing::Wilds | Landing::Nowhere => ("·", DIM), } } /// What you'll find if you land or dock at a place, in a sentence. fn landing_line(s: &System, place: usize, width: usize) -> Vec> { let landing = s.landing(place); let (mark, colour) = mark(landing); let (tag, rest) = match landing { Landing::Capital(c) => ( "Capital", format!( ": {}. Land and walk in to its broker, doctor, scholar, tavern and shipyard, \ which sells fuel.", town::name(town::seed_for(s.seed, place), c) ), ), Landing::Market(_) => { ("Trade only", ": dock to sell ore and buy fuel. No city, no shops.".to_string()) } Landing::Aloft(c) => ( "Neither", format!(": {} live up in its clouds, but there's no ground to land on.", c.name()), ), Landing::Wilds => { ("Neither", ": no one lives here to trade with. Land and look around.".to_string()) } Landing::Nowhere if s.places[place].kind == PlaceKind::Belt => { ("Neither", ": nowhere to land, only rocks to mine.".to_string()) } Landing::Nowhere => { ("Neither", ": no one lives here, and there's no ground to land on.".to_string()) } }; let head = vec![Span::raw(format!("{mark} ")).fg(colour)]; let phrases = [(format!("{tag}{rest}"), Style::new())]; let mut lines = wrap(head, &phrases, width); // Colour the tag, which is always the first word or two. if let Some(first) = lines.first_mut() { let words = tag.split(' ').count(); for span in first.spans.iter_mut().skip(1).step_by(2).take(words) { span.style = Style::new().fg(colour).bold(); } } lines } /// The survey, as labelled rows. fn facts(survey: &Survey, station: bool, giant: bool, width: usize) -> Vec> { let plain = Style::new(); let dim = Style::new().fg(DIM); let mut lines = Vec::new(); if let Some(t) = survey.temperature { lines.push(thermometer(t)); let range = (t.hottest - t.coldest >= 2.0).then(|| { format!("{} to {} °C", survey::celsius(t.coldest), survey::celsius(t.hottest)) }); let detail = match (giant, range) { (true, Some(range)) => Some(format!("{range} at the cloud tops")), (true, None) => Some("at the cloud tops".to_string()), (false, Some(range)) => Some(format!("{range} over its year")), (false, None) => None, }; if let Some(detail) = detail { lines.extend(fact("", &[(detail, dim)], width)); } } if let Some(day) = &survey.day { let mut text = day.clone(); if let Some(seasons) = survey.seasons { text = format!("{text}; {seasons}"); } lines.extend(fact(if station { "spin" } else { "day" }, &[(text, plain)], width)); } if let Some(g) = survey.gravity { let amount = if g < 0.1 { format!("{g:.2} g") } else { format!("{g:.1} g") }; let feel = match g { g if g < 0.3 => "featherlight", g if g < 0.75 => "light", g if g < 1.25 => "", g if g < 1.8 => "heavy", _ => "crushing", }; let mut parts = vec![(amount, plain)]; if !feel.is_empty() { parts.push((format!("· {feel}"), dim)); } if station { parts.push(("· from its spin".to_string(), dim)); } lines.extend(fact("pull", &parts, width)); } let suit_up = ("suit up".to_string(), Style::new().fg(SUIT_UP)); let air = match survey.air { Some((what, true)) => { vec![ (what.to_string(), plain), ("·".into(), dim), ("breathable".into(), Style::new().fg(CAPITAL)), ] } Some((what, false)) => vec![(what.to_string(), plain), ("·".into(), dim), suit_up], None => vec![("none".to_string(), plain), ("·".into(), dim), suit_up], }; lines.extend(fact("air", &air, width)); lines.extend(fact("sky", &[(survey.sky.clone(), plain)], width)); if let Some(n) = survey.population { let mut text = headcount(n); if giant { text.push_str(", in cloud cities"); } lines.extend(fact(if station { "aboard" } else { "people" }, &[(text, plain)], width)); } lines } /// A labelled row of the survey. fn fact(label: &str, phrases: &[(String, Style)], width: usize) -> Vec> { let head = vec![Span::raw(format!("{label:>, phrases: &[(String, Style)], width: usize, ) -> Vec> { let indent: usize = head.iter().map(|s| s.content.chars().count()).sum(); let room = width.saturating_sub(indent).max(10); let mut rows: Vec>> = vec![Vec::new()]; let mut used = 0; for (text, style) in phrases { for word in text.split_whitespace() { let w = word.chars().count(); if used > 0 && used + 1 + w > room { rows.push(Vec::new()); used = 0; } let row = rows.last_mut().unwrap(); if used > 0 { row.push(Span::raw(" ")); used += 1; } row.push(Span::styled(word.to_string(), *style)); used += w; } } let mut head = Some(head); rows.into_iter() .map(|spans| { let mut line = head.take().unwrap_or_else(|| vec![Span::raw(" ".repeat(indent))]); line.extend(spans); Line::from(line) }) .collect() } fn capitalise(s: &str) -> String { let mut chars = s.chars(); chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) } /// Cells in the thermometer. const THERMOMETER: usize = 14; /// The thermometer's ends, in kelvin. It's marked on a log scale, so the /// temperate middle isn't squeezed. const COLDEST: f64 = 50.0; const HOTTEST: f64 = 1500.0; /// A thermometer from deep cold to molten, filled up to the temperature /// now and lit more faintly over the rest of its year's swing; then the /// temperature and a word for it. fn thermometer(t: Temperature) -> Line<'static> { let cell = |kelvin: f64| { let k = (kelvin / COLDEST).ln() / (HOTTEST / COLDEST).ln(); (k.clamp(0.0, 1.0) * (THERMOMETER - 1) as f64).round() as usize }; let (low, high, now) = (cell(t.coldest), cell(t.hottest), cell(t.now)); let mut spans = vec![Span::raw(format!("{: Rgb { const STOPS: [(f64, Rgb); 7] = [ (50.0, (96, 112, 236)), (150.0, (120, 178, 255)), (240.0, (176, 226, 244)), (FREEZING + 15.0, (128, 218, 138)), (320.0, (240, 210, 96)), (450.0, (246, 142, 64)), (900.0, (236, 72, 52)), ]; let k = kelvin.clamp(STOPS[0].0, STOPS[STOPS.len() - 1].0); let i = STOPS.iter().rposition(|&(at, _)| at <= k).unwrap().min(STOPS.len() - 2); let ((a, ca), (b, cb)) = (STOPS[i], STOPS[i + 1]); let f = ((k / a).ln() / (b / a).ln()).clamp(0.0, 1.0); let mix = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * f).round() as u8; (mix(ca.0, cb.0), mix(ca.1, cb.1), mix(ca.2, cb.2)) } /// "2.1 billion", "48 million", "12,000". fn headcount(n: u64) -> String { let short = |x: f64, unit: &str| { let text = if x < 10.0 { format!("{x:.1}") } else { format!("{x:.0}") }; format!("{} {unit}", text.trim_end_matches(".0")) }; match n { n if n >= 1_000_000_000 => short(n as f64 / 1e9, "billion"), n if n >= 1_000_000 => short(n as f64 / 1e6, "million"), n => { let digits = n.to_string(); let mut out = String::new(); for (i, ch) in digits.chars().enumerate() { if i > 0 && (digits.len() - i) % 3 == 0 { out.push(','); } out.push(ch); } out } } } fn draw_orbits(c: &mut Context, s: &System, scale: &Scale) { for planet in &s.planets { let points: Vec<(f64, f64)> = planet.orbit.outline(240).map(|p| scale.point(p)).collect(); c.draw(&Points { coords: &points, color: ORBIT }); } // The belt: a scatter of dots, the same every time. let belt: Vec<(f64, f64)> = (0..700) .map(|i| { let angle = sky::hash01(s.seed, i, 1) * TAU; let r = s.belt.inner + (s.belt.outer - s.belt.inner) * sky::hash01(s.seed, i, 2); scale.point(Vec2::new(r * angle.cos(), r * angle.sin())) }) .collect(); c.draw(&Points { coords: &belt, color: Color::Rgb(104, 96, 86) }); } #[cfg(test)] mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; use crate::scenes::tests::test_ctx; fn press(map: &mut SystemMap, ctx: &mut Ctx, code: KeyCode) -> Transition { map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) } #[test] fn travelling_takes_days_and_moves_you() { let mut ctx = test_ctx(); let mut map = SystemMap::new(&ctx); let start = ctx.location; press(&mut map, &mut ctx, KeyCode::Down); let destination = map.selected; let days = SystemMap::trip(&ctx, destination); assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop)); assert_eq!(ctx.location, destination); assert_ne!(ctx.location, start); assert!((ctx.day - days).abs() < 1e-9 && days > 0.0); assert_eq!(ctx.departed, Some(0.0)); } #[test] fn a_beacon_that_fails_on_the_way_still_leads_you_there() { let ctx = test_ctx(); let w = ctx.system.wrecks_on(0.0)[0].clone(); let warning = beacon_warning(&w, 0.0, w.leaves + 1.0).unwrap(); assert!(warning.contains("last fix"), "{warning}"); assert_eq!(beacon_warning(&w, 0.0, 0.5), None, "plenty of time"); let failed = w.leaves + 1.0; assert_eq!(beacon_warning(&w, failed, failed), None, "you're there already"); } #[test] fn choosing_where_you_are_goes_nowhere() { let mut ctx = test_ctx(); let mut map = SystemMap::new(&ctx); press(&mut map, &mut ctx, KeyCode::Enter); assert_eq!(ctx.day, 0.0); press(&mut map, &mut ctx, KeyCode::Char('.')); assert_eq!(ctx.day, 1.0, "but you can wait"); } #[test] fn numbers_read_naturally() { assert_eq!(headcount(2_100_000_000), "2.1 billion"); assert_eq!(headcount(3_000_000_000), "3 billion"); assert_eq!(headcount(48_000_000), "48 million"); assert_eq!(headcount(12_000), "12,000"); assert_eq!(headcount(870), "870"); } #[test] fn the_map_says_where_the_capitals_are() { let ctx = test_ctx(); let mut map = SystemMap::new(&ctx); map.selected = ctx.system.home_place(); let text: String = map .about(&ctx, 44) .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.to_string())) .collect(); assert!(text.contains("⌂ Capital: "), "{text}"); assert!(text.contains("°C"), "{text}"); let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); assert_eq!(mark(ctx.system.landing(belt)).0, "·"); } #[test] fn the_list_wraps_round() { let mut ctx = test_ctx(); let mut map = SystemMap::new(&ctx); map.selected = 0; press(&mut map, &mut ctx, KeyCode::Up); assert_eq!(map.selected, ctx.system.places.len() - 1); } }