//! The star map: every system in the galaxy, the lanes between them, and //! whose space is whose. //! //! The map is drawn at one scale, a light-year to a cell across (two down), //! and scrolls to keep the chosen system in view; an inset in the corner //! shows the whole galaxy and where the view is. Each people's territory is //! a faint stipple in their colour round the systems they hold, brighter //! along the borders. //! //! You know the way only to systems you've charted: those you've been to //! and those one lane from them. Choose one and the map plots the fewest //! jumps there through charted space; `enter` makes them, a jump's worth of //! fuel at a time. Beyond the charted systems are faint dots, unnamed, //! except for the four seats, which everyone knows. //! //! Run dry where no one sells fuel and `t` calls a tow, to the nearest //! system that does. It costs what it costs: what you can't pay, you owe. use std::collections::HashSet; use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style, Stylize}; use ratatui::symbols::Marker; use ratatui::text::{Line, Span}; use ratatui::widgets::canvas::{Canvas, Line as Lane, Points}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use super::keys; use super::port::FUEL; use super::sky; use super::system_map::{FAILING, FAINT_WRECK, SystemMap, wrap}; use super::{Scene, Transition}; use crate::app::Ctx; use crate::names::{self, Culture}; use crate::space::Vec2; use crate::space::galaxy::Galaxy; use crate::space::system::{Landing, Rgb, System, SystemKind}; use crate::trade; const PANEL_WIDTH: u16 = 44; /// Below this width the panel goes under the map instead of beside it. const SIDE_BY_SIDE: u16 = 90; const ACCENT: Color = Color::Rgb(240, 200, 110); const DIM: Color = Color::Rgb(110, 114, 132); const LANE: Color = Color::Rgb(86, 92, 116); const FAINT_LANE: Color = Color::Rgb(50, 53, 68); const NO_FUEL: Color = Color::Rgb(214, 112, 100); /// How far a people's territory reaches past their outermost stars, in /// light-years. const REACH: f64 = 11.0; /// The inset showing the whole galaxy, inside its border. const INSET: (u16, u16) = (26, 8); /// A people's colour on the map: the colour of their stations. pub fn people_rgb(culture: Culture) -> Rgb { sky::station_art(culture).1 } pub struct GalaxyMap { selected: usize, /// The chosen system, generated, if you've been there and it isn't /// where you are (that's `ctx.system`). known: Option, /// What happened when you last tried to go somewhere. report: Option, } impl GalaxyMap { pub fn new(ctx: &Ctx) -> GalaxyMap { GalaxyMap { selected: ctx.star, known: None, report: None } } fn select(&mut self, site: usize, ctx: &Ctx) { if site == self.selected { return; } self.selected = site; self.report = None; self.known = (site != ctx.star && ctx.visited.contains(&site)).then(|| ctx.galaxy.system(site)); } /// The system nearest the chosen one in a direction (y up), within a /// cone either side of it. fn step(&mut self, dir: Vec2, ctx: &Ctx) { let g = &ctx.galaxy; let from = g.sites[self.selected].pos; let best = g .sites .iter() .enumerate() .filter(|&(i, _)| i != self.selected) .filter_map(|(i, s)| { let off = s.pos - from; let d = off.length(); let cos = off.dot(dir) / d; (cos > 0.5).then_some((i, d * (1.0 + 2.0 * (1.0 - cos)))) }) .min_by(|a, b| a.1.total_cmp(&b.1)); if let Some((i, _)) = best { self.select(i, ctx); } } /// The next system a lane from here leads to. fn next_lane(&mut self, ctx: &Ctx) { let lanes = &ctx.galaxy.sites[ctx.star].lanes; let next = match lanes.iter().position(|&l| l == self.selected) { Some(i) => lanes[(i + 1) % lanes.len()], None => lanes[0], }; self.select(next, ctx); } /// The jumps from here to the chosen system, through charted space. fn route(&self, ctx: &Ctx, charted: &[bool]) -> Option> { if !charted[self.selected] { return None; } ctx.galaxy.route(ctx.star, self.selected, |i| charted[i]) } /// Jumps to the chosen system, if you can. fn travel(&mut self, ctx: &mut Ctx) -> Transition { let g = &ctx.galaxy; let to = self.selected; let charted = g.charted(&ctx.visited); let refusal = if to == ctx.star { Some(format!("You're in {} already.", g.sites[to].name)) } else if !charted[to] { Some( "That system's uncharted: you don't know the lanes that far. Jump closer first." .into(), ) } else { None }; if let Some(refusal) = refusal { self.report = Some(refusal); return Transition::None; } let Some(route) = self.route(ctx, &charted) else { self.report = Some("There's no charted route there.".into()); return Transition::None; }; let jumps = route.len() as u32; let fuel = ctx.hold.fuel; if fuel < jumps { self.report = Some(if fuel == 0 && ctx.stranded() { "Your tank's dry, and no one here sells fuel. [t] calls a tow.".into() } else if fuel == 0 { "Your tank's dry. Fuel's sold here: at a station, or a city's shipyard.".into() } else { format!( "That's {jumps} jumps, and you've fuel for {}. Choose somewhere nearer, \ or fill up first.", plural(fuel, "jump") ) }); return Transition::None; } let days = g.route_days(ctx.star, &route); let name = g.sites[to].name.clone(); ctx.day += days; ctx.hold.fuel -= jumps; ctx.visited.extend(route.iter().copied()); ctx.enter_system(to); ctx.arrival_note = Some(format!( "{} and {} later, you come out of the jump at the edge of the {name} system, {{where}}.", capitalise(&plural(jumps, "jump")), days_text(days) )); Transition::Pop } /// Calls a tow, if you're stranded. fn tow(&mut self, ctx: &mut Ctx) -> Transition { if !ctx.stranded() { self.report = Some(if ctx.hold.fuel > 0 { "You've fuel enough to jump. No one tows a ship that can fly.".into() } else { "Fuel's for sale here, at a station or a city's shipyard. No tow will come for that." .into() }); return Transition::None; } let g = &ctx.galaxy; let Some(route) = g.nearest(ctx.star, |i| g.sites[i].kind.sells_fuel()) else { self.report = Some("No one answers.".into()); return Transition::None; }; let to = *route.last().unwrap(); let fee = trade::tow_fee(route.len()); // Towing is slow: twice as long as jumping yourself. let days = 2.0 * g.route_days(ctx.star, &route); let name = g.sites[to].name.clone(); ctx.day += days; ctx.visited.extend(route.iter().copied()); let owed = ctx.hold.charge(fee); ctx.enter_system(to); if let Some(place) = ctx.system.fuel_place() { ctx.location = place; } let bill = if owed == 0 { format!("The fee is {fee} ¤, and you pay it.") } else { format!( "The fee is {fee} ¤. You pay what you can, and owe {owed} ¤; \ what you earn goes to it first." ) }; ctx.arrival_note = Some(format!( "{} later the tow casts you off in the {name} system, {{where}}. {bill}", capitalise(&days_text(days)) )); Transition::Pop } } impl Scene for GalaxyMap { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { match key.code { KeyCode::Up | KeyCode::Char('w' | 'k') => self.step(Vec2::new(0.0, 1.0), ctx), KeyCode::Down | KeyCode::Char('s' | 'j') => self.step(Vec2::new(0.0, -1.0), ctx), KeyCode::Left | KeyCode::Char('a' | 'h') => self.step(Vec2::new(-1.0, 0.0), ctx), KeyCode::Right | KeyCode::Char('d' | 'l') => self.step(Vec2::new(1.0, 0.0), ctx), KeyCode::Tab => self.next_lane(ctx), KeyCode::Char('.') => self.select(ctx.star, ctx), KeyCode::Enter => return self.travel(ctx), KeyCode::Char('t') => return self.tow(ctx), KeyCode::Char('m') => return Transition::Replace(vec![Box::new(SystemMap::new(ctx))]), KeyCode::Esc | KeyCode::Char('g' | 'q') => return Transition::Pop, _ => {} } Transition::None } fn render(&self, frame: &mut Frame, ctx: &Ctx) { let area = frame.area(); let (map, panel) = if area.width >= SIDE_BY_SIDE { let [map, panel] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) .areas(area); (map, panel) } else { let rows = (area.height / 2).min(16); let [map, panel] = Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(area); (map, panel) }; let charted = ctx.galaxy.charted(&ctx.visited); let route = self.route(ctx, &charted).unwrap_or_default(); if map.width >= 4 && map.height >= 2 { let view = View::new(&ctx.galaxy, map, ctx.galaxy.sites[self.selected].pos); self.draw_map(frame, &view, ctx, &charted, &route); if map.width >= 60 && map.height >= 18 && !view.shows_all(&ctx.galaxy) { draw_inset(frame.buffer_mut(), map, &view, ctx, self.selected); } } self.draw_panel(frame, panel, ctx, &charted, &route); } } /// Which part of the galaxy is on screen: a light-year to a cell across, /// two to a cell down. struct View { area: Rect, left: f64, top: f64, } impl View { /// Centred on `focus`, as far as that doesn't scroll off into empty /// space. fn new(galaxy: &Galaxy, area: Rect, focus: Vec2) -> View { let (low, high) = galaxy.bounds(); let margin = 8.0; let w = f64::from(area.width); let h = f64::from(area.height) * 2.0; let fit = |focus: f64, low: f64, high: f64, span: f64| { if high - low + 2.0 * margin <= span { (low + high) / 2.0 } else { focus.clamp(low - margin + span / 2.0, high + margin - span / 2.0) } }; let cx = fit(focus.x, low.x, high.x, w); let cy = fit(focus.y, low.y, high.y, h); View { area, left: cx - w / 2.0, top: cy + h / 2.0 } } fn right(&self) -> f64 { self.left + f64::from(self.area.width) } fn bottom(&self) -> f64 { self.top - 2.0 * f64::from(self.area.height) } /// The cell a point falls in, if it's on screen. fn cell(&self, p: Vec2) -> Option<(u16, u16)> { let col = (p.x - self.left).floor(); let row = ((self.top - p.y) / 2.0).floor(); let inside = col >= 0.0 && row >= 0.0 && col < f64::from(self.area.width) && row < f64::from(self.area.height); inside.then(|| (self.area.x + col as u16, self.area.y + row as u16)) } fn shows_all(&self, galaxy: &Galaxy) -> bool { let (low, high) = galaxy.bounds(); low.x >= self.left && high.x < self.right() && high.y <= self.top && low.y > self.bottom() } } /// Which people's territory a point is in, if any, and whether it's near /// a border. fn territory(galaxy: &Galaxy, p: Vec2) -> Option<(Culture, bool)> { let mut nearest = (f64::MAX, Culture::Qaari); for s in &galaxy.sites { let d = s.pos.distance(p); if d < nearest.0 { nearest = (d, s.culture); } } if nearest.0 > REACH { return None; } let other = galaxy .sites .iter() .filter(|s| s.culture != nearest.1) .map(|s| s.pos.distance(p)) .fold(f64::MAX, f64::min); Some((nearest.1, other - nearest.0 < 1.6)) } impl GalaxyMap { fn draw_map( &self, frame: &mut Frame, view: &View, ctx: &Ctx, charted: &[bool], route: &[usize], ) { let g = &ctx.galaxy; let area = view.area; // Territory: a faint dot in every other cell, and every cell along // a border. Each people's dots are drawn together, in their colour. let mut tint: Vec<(Color, Vec<(f64, f64)>)> = Vec::new(); for c in Culture::ALL { let rgb = people_rgb(c); tint.push((sky::shade(rgb, 0.5), Vec::new())); tint.push((sky::shade(rgb, 0.85), Vec::new())); } for row in 0..area.height { for col in 0..area.width { let p = Vec2::new( view.left + f64::from(col) + 0.25, view.top - 2.0 * f64::from(row) - 0.75, ); let Some((culture, border)) = territory(g, p) else { continue }; if border || (col + row) % 2 == 0 { tint[culture as usize * 2 + usize::from(border)].1.push((p.x, p.y)); } } } let on_route = |a: usize, b: usize| { let mut stops = vec![ctx.star]; stops.extend(route); stops.windows(2).any(|w| (w[0] == a && w[1] == b) || (w[0] == b && w[1] == a)) }; let canvas = Canvas::default() .marker(Marker::Braille) .background_color(Color::Reset) .x_bounds([view.left, view.right()]) .y_bounds([view.bottom(), view.top]) .paint(|c| { for (color, coords) in &tint { c.draw(&Points { coords, color: *color }); } c.layer(); for (i, s) in g.sites.iter().enumerate() { for &j in s.lanes.iter().filter(|&&j| j > i) { let colour = match (charted[i], charted[j]) { _ if on_route(i, j) => continue, (true, true) => LANE, (true, false) | (false, true) => FAINT_LANE, (false, false) => continue, }; let t = &g.sites[j]; c.draw(&Lane::new(s.pos.x, s.pos.y, t.pos.x, t.pos.y, colour)); } } c.layer(); let mut at = g.sites[ctx.star].pos; for &next in route { let to = g.sites[next].pos; c.draw(&Lane::new(at.x, at.y, to.x, to.y, ACCENT)); at = to; } }); frame.render_widget(canvas, area); self.draw_stars(frame.buffer_mut(), view, ctx, charted, route); } /// Each star, then names, most important first, wherever they fit. fn draw_stars( &self, buf: &mut Buffer, view: &View, ctx: &Ctx, charted: &[bool], route: &[usize], ) { let g = &ctx.galaxy; let mut taken: HashSet<(u16, u16)> = HashSet::new(); for (i, s) in g.sites.iter().enumerate() { let Some(cell) = view.cell(s.pos) else { continue }; let rgb = people_rgb(s.culture); let (glyph, style) = if i == ctx.star { ("@", Style::new().fg(Color::White).bold()) } else if g.is_seat(i) && (charted[i] || ctx.visited.contains(&i)) { ("◉", Style::new().fg(sky::rgb(rgb)).bold()) } else if g.is_seat(i) { ("◉", Style::new().fg(sky::shade(rgb, 0.7))) } else if ctx.visited.contains(&i) { ("●", Style::new().fg(sky::rgb(rgb))) } else if charted[i] { ("○", Style::new().fg(sky::rgb(rgb))) } else { ("·", Style::new().fg(sky::shade(rgb, 0.75))) }; buf[cell].set_symbol(glyph).set_style(style); taken.insert(cell); if i == self.selected { for (dx, bracket) in [(-1i32, "["), (1, "]")] { let x = i32::from(cell.0) + dx; if x >= i32::from(view.area.x) && x < i32::from(view.area.right()) { buf[(x as u16, cell.1)].set_symbol(bracket).set_fg(ACCENT); taken.insert((x as u16, cell.1)); } } } } // Who gets a name, in order: you, the choice, the way there, the // seats, where you've been, what's charted. let mut order: Vec = vec![ctx.star, self.selected]; order.extend(route); order.extend((0..g.sites.len()).filter(|&i| g.is_seat(i))); order.extend(ctx.visited.iter().copied()); order.extend((0..g.sites.len()).filter(|&i| charted[i])); let mut named = HashSet::new(); for i in order { if !named.insert(i) { continue; } let s = &g.sites[i]; let Some((col, row)) = view.cell(s.pos) else { continue }; let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i); let text = if known { s.name.clone() } else { "uncharted".to_string() }; let fg = if i == self.selected { ACCENT } else if i == ctx.star { Color::White } else if ctx.visited.contains(&i) && !s.kind.sells_fuel() { NO_FUEL } else if ctx.visited.contains(&i) { Color::Rgb(200, 204, 216) } else if charted[i] { Color::Rgb(148, 152, 170) } else { sky::shade(people_rgb(s.culture), 0.7) }; let start = col + 2; let width = text.chars().count() as u16; let end = start.saturating_add(width).min(view.area.right()); if start >= end { continue; } let must = i == ctx.star || i == self.selected; let clear = (start.saturating_sub(1)..end + 1).all(|x| !taken.contains(&(x, row))); if !(must || clear) { continue; } let shown: String = text.chars().take(usize::from(end - start)).collect(); buf.set_string(start, row, shown, Style::new().fg(fg)); for x in start..end { taken.insert((x, row)); } } } fn draw_panel( &self, frame: &mut Frame, area: Rect, ctx: &Ctx, charted: &[bool], route: &[usize], ) { let block = Block::bordered() .title(format!(" star map · day {:.1} ", ctx.day)) .title_style(Style::new().fg(ACCENT).bold()) .border_style(Style::new().fg(DIM)); let inner = block.inner(area); frame.render_widget(block, area); let mut hints = vec![("←↑↓→", "choose"), ("tab", "lanes"), ("enter", "jump")]; if ctx.stranded() { hints.push(("t", "tow")); } hints.extend([(".", "where you are"), ("m", "system map"), ("esc", "back")]); let hints = keys::packed(&hints, inner.width); let [about, keys] = Layout::vertical([Constraint::Fill(1), Constraint::Length(2 + hints.len() as u16)]) .areas(inner); let width = usize::from(about.width); frame.render_widget( Paragraph::new(self.about(ctx, charted, route, width)).wrap(Wrap { trim: false }), about, ); let mut legend = vec![ Line::from(vec![ Span::raw("@").fg(Color::White).bold(), Span::raw(" you ").fg(DIM), Span::raw("◉").fg(DIM), Span::raw(" seat ").fg(DIM), Span::raw("●").fg(DIM), Span::raw(" been ").fg(DIM), Span::raw("○").fg(DIM), Span::raw(" charted ").fg(DIM), Span::raw("·").fg(DIM), Span::raw(" not").fg(DIM), ]), Line::from( Culture::ALL .into_iter() .flat_map(|c| { [ Span::raw("■ ").fg(sky::rgb(people_rgb(c))), Span::raw(format!("{} ", c.adjective())).fg(DIM), ] }) .collect::>(), ), ]; legend.extend(hints); frame.render_widget(Paragraph::new(legend), keys); } /// What's known about the chosen system, and the way there. fn about( &self, ctx: &Ctx, charted: &[bool], route: &[usize], width: usize, ) -> Vec> { let g = &ctx.galaxy; let i = self.selected; let site = &g.sites[i]; let h = &ctx.hold; let dim = Style::new().fg(DIM); let plain = Style::new(); let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)]; let gauge: String = (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect(); let mut lines = vec![Line::from(vec![ Span::raw(format!("{:<8}", "fuel")).fg(DIM), Span::raw(gauge).fg(FUEL), Span::raw(format!(" {} of {}", h.fuel, plural(h.fuel_capacity, "jump"))), ])]; if h.debt > 0 { lines.push(Line::from(vec![ Span::raw(format!("{:<8}", "owed")).fg(DIM), Span::raw(format!("{} ¤, paid from what you earn", h.debt)), ])); } if ctx.stranded() { lines.push(Line::default()); let (to, fee) = match g.nearest(ctx.star, |s| g.sites[s].kind.sells_fuel()) { Some(r) => (g.sites[*r.last().unwrap()].name.clone(), trade::tow_fee(r.len())), None => ("nowhere".into(), 0), }; lines.extend(wrap( vec![Span::raw("Stranded. ").fg(NO_FUEL).bold()], &[( format!( "Your tank's dry and no one here sells fuel. [t] calls a tow to {to}, \ for {fee} ¤; what you can't pay, you'll owe." ), plain, )], width, )); } lines.push(Line::default()); let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i); let title = if known { site.name.clone() } else { "An uncharted system".into() }; lines.push(Line::from(title).fg(ACCENT).bold()); let whose = site.culture.name(); let what = if ctx.visited.contains(&i) { match site.kind { SystemKind::Seat => format!("the seat of {whose}"), kind => format!("{} of {whose}", kind.name()), } } else if g.is_seat(i) { format!("the seat of {whose}; you've not been there") } else if charted[i] { format!("in the space of {whose}; you've not been there") } else { format!("in the space of {whose}, past what you've charted") }; let head = vec![Span::raw("■ ").fg(sky::rgb(people_rgb(site.culture)))]; lines.extend(wrap(head, &[(what, plain)], width)); // The way there. if i == ctx.star { let lanes: Vec = site.lanes.iter().map(|&l| g.sites[l].name.clone()).collect(); lines.extend(wrap( label("lanes"), &[(format!("to {}", join_names(&lanes)), plain)], width, )); } else if !charted[i] { lines.extend(wrap( Vec::new(), &[("Uncharted: jump closer to learn the way.".into(), dim)], width, )); } else if route.is_empty() { lines.extend(wrap(Vec::new(), &[("No charted route there.".into(), dim)], width)); } else { let jumps = route.len() as u32; let days = g.route_days(ctx.star, route); let short = h.fuel < jumps; let fuel_text = format!("· {} of fuel", plural(jumps, "jump")); lines.extend(wrap( label("route"), &[ (format!("{}, {}", plural(jumps, "jump"), days_text(days)), plain), ( fuel_text, if short { Style::new().fg(NO_FUEL) } else { Style::new().fg(FUEL) }, ), ], width, )); if route.len() > 1 { let via: Vec = route[..route.len() - 1].iter().map(|&s| g.sites[s].name.clone()).collect(); lines.extend(wrap(label("via"), &[(via.join(" → "), plain)], width)); } if short { lines.extend(wrap( Vec::new(), &[( format!("You've fuel for {}.", plural(h.fuel, "jump")), Style::new().fg(NO_FUEL), )], width, )); } } if let Some(report) = &self.report { lines.push(Line::default()); lines.extend(wrap(Vec::new(), &[(report.clone(), Style::new().fg(ACCENT))], width)); } lines.push(Line::default()); // What it is, and what's there if you've been. if known { lines.extend(wrap( label("star"), &[ (names::a(site.star.name), plain), (format!("· {:.0} ly from here", g.distance(ctx.star, i)), dim), ], width, )); } if let Some(neighbour) = site.neighbour.filter(|_| ctx.visited.contains(&i)) { lines.extend(wrap( label("border"), &[(format!("with {}, who settle here too", neighbour.name()), plain)], width, )); } let system = if i == ctx.star { Some(&ctx.system) } else { self.known.as_ref() }; if let Some(s) = system { lines.extend(wrap(Vec::new(), &[(site.kind.blurb().to_string(), dim)], width)); lines.extend(contents(s, ctx, width)); } else if charted[i] { lines.extend(wrap( Vec::new(), &[("What's there, you'll know when you've been. Ask in a tavern.".into(), dim)], width, )); } lines } } /// What's in a system you've been to: its capitals, stations, fuel and /// wrecks. fn contents(s: &System, ctx: &Ctx, width: usize) -> Vec> { let plain = Style::new(); let dim = Style::new().fg(DIM); let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)]; let mut lines = Vec::new(); let capitals: Vec = (0..s.places.len()) .filter(|&p| matches!(s.landing(p), Landing::Capital(_))) .map(|p| s.places[p].name.clone()) .collect(); let worlds = s.planets.len(); let mut parts = vec![(plural(worlds as u32, "world"), plain)]; match capitals.len() { 0 => parts.push(("· no capital".into(), dim)), _ => parts.push(( format!("· ⌂ {}", join_names(&capitals)), Style::new().fg(Color::Rgb(130, 214, 140)), )), } lines.extend(wrap(label("worlds"), &parts, width)); if !s.stations.is_empty() { let names: Vec = s.stations.iter().map(|st| st.name.clone()).collect(); lines.extend(wrap(label("docks"), &[(join_names(&names), plain)], width)); } let fuel = if s.sells_fuel() { ("for sale".to_string(), Style::new().fg(FUEL)) } else { ("none for sale".to_string(), Style::new().fg(NO_FUEL)) }; lines.extend(wrap(label("fuel"), &[fuel], width)); let beacons = s.beacons(ctx.day, &ctx.stripped); if !beacons.is_empty() { let failing = beacons.iter().filter(|w| w.leaves - ctx.day <= FAILING).count(); let mut parts = vec![( format!("{} calling", plural(beacons.len() as u32, "beacon")), Style::new().fg(Color::Rgb(255, 120, 100)), )]; if failing > 0 { parts.push((format!("· {failing} failing"), Style::new().fg(FAINT_WRECK))); } lines.extend(wrap(label("wrecks"), &parts, width)); } if s.belt.density > 1.0 { lines.extend(wrap( label("belt"), &[(format!("{}: dense, and rich in ore", s.belt.name), plain)], width, )); } lines } /// The whole galaxy, small, in the map's top-left corner: each people's /// space, where you are, the choice, and the part the map shows. fn draw_inset(buf: &mut Buffer, map: Rect, view: &View, ctx: &Ctx, selected: usize) { let g = &ctx.galaxy; let (w, h) = INSET; let outer = Rect::new(map.x, map.y, w + 2, h + 2); let block = Block::bordered() .title(" galaxy ") .title_style(Style::new().fg(DIM)) .border_style(Style::new().fg(DIM)); let inner = block.inner(outer); ratatui::widgets::Clear.render(outer, buf); block.render(outer, buf); let (low, high) = g.bounds(); let margin = 6.0; let (low, high) = (low - Vec2::new(margin, margin), high + Vec2::new(margin, margin)); let to_cell = |p: Vec2| { let col = ((p.x - low.x) / (high.x - low.x) * f64::from(w)).floor(); let row = ((high.y - p.y) / (high.y - low.y) * f64::from(h)).floor(); (col.clamp(0.0, f64::from(w - 1)) as u16, row.clamp(0.0, f64::from(h - 1)) as u16) }; for row in 0..h { for col in 0..w { let p = Vec2::new( low.x + (f64::from(col) + 0.5) / f64::from(w) * (high.x - low.x), high.y - (f64::from(row) + 0.5) / f64::from(h) * (high.y - low.y), ); let cell = &mut buf[(inner.x + col, inner.y + row)]; cell.set_symbol(" "); if let Some((c, _)) = territory(g, p) { cell.set_bg(sky::shade(people_rgb(c), 0.28)); } } } // The view, as corners. let (a, b) = (to_cell(Vec2::new(view.left, view.top)), to_cell(Vec2::new(view.right(), view.bottom()))); for (x, y, ch) in [(a.0, a.1, "┌"), (b.0, a.1, "┐"), (a.0, b.1, "└"), (b.0, b.1, "┘")] { buf[(inner.x + x, inner.y + y)].set_symbol(ch).set_fg(Color::Rgb(200, 204, 216)); } let (x, y) = to_cell(g.sites[selected].pos); buf[(inner.x + x, inner.y + y)].set_symbol("+").set_style(Style::new().fg(ACCENT).bold()); let (x, y) = to_cell(g.sites[ctx.star].pos); buf[(inner.x + x, inner.y + y)].set_symbol("@").set_style(Style::new().fg(Color::White).bold()); } /// "1 jump", "3 jumps". fn plural(n: u32, what: &str) -> String { if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") } } fn days_text(days: f64) -> String { format!("{days:.1} days") } /// "A", "A and B", "A, B and C". fn join_names(names: &[String]) -> String { match names { [] => String::new(), [one] => one.clone(), [rest @ .., last] => format!("{} and {last}", rest.join(", ")), } } fn capitalise(s: &str) -> String { let mut chars = s.chars(); chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) } #[cfg(test)] mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; use crate::scenes::tests::test_ctx; fn press(map: &mut GalaxyMap, ctx: &mut Ctx, code: KeyCode) -> Transition { map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) } #[test] fn jumping_along_a_lane_costs_fuel_and_days() { let mut ctx = test_ctx(); let mut map = GalaxyMap::new(&ctx); let start = ctx.star; press(&mut map, &mut ctx, KeyCode::Tab); let to = map.selected; assert!(ctx.galaxy.sites[start].lanes.contains(&to)); let days = ctx.galaxy.jump_days(start, to); ctx.departed = Some(0.0); assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop)); assert_eq!(ctx.star, to); assert_eq!(ctx.departed, None, "no wreck here waits for you"); assert_eq!(ctx.system.name, ctx.galaxy.sites[to].name); assert_eq!(ctx.location, ctx.system.edge_place(), "you come out at the edge"); assert_eq!(ctx.hold.fuel, crate::app::Hold::BASE_FUEL - 1); assert!((ctx.day - days).abs() < 1e-9); assert!(ctx.visited.contains(&to)); assert!(ctx.arrival_note.as_ref().unwrap().contains("1 jump and")); } #[test] fn you_can_only_plot_through_charted_space_and_on_the_fuel_you_have() { let mut ctx = test_ctx(); let g = &ctx.galaxy; let charted = g.charted(&ctx.visited); let far = (0..g.sites.len()).find(|&i| !charted[i]).unwrap(); let mut map = GalaxyMap::new(&ctx); map.select(far, &ctx); assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None)); assert!(map.report.as_ref().unwrap().contains("uncharted")); let next = ctx.galaxy.sites[ctx.star].lanes[0]; map.select(next, &ctx); ctx.hold.fuel = 0; assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None)); assert!(map.report.as_ref().unwrap().contains("dry")); assert_eq!(ctx.day, 0.0); } #[test] fn stranded_ships_are_towed_and_owe_for_it() { let mut ctx = test_ctx(); let g = &ctx.galaxy; let dead = (0..g.sites.len()).find(|&i| !g.sites[i].kind.sells_fuel()).unwrap(); ctx.enter_system(dead); ctx.hold.fuel = 0; ctx.hold.scrip = 20; assert!(ctx.stranded()); let mut map = GalaxyMap::new(&ctx); assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::Pop)); assert!(ctx.system.sells_fuel()); assert!(ctx.system.sells_fuel_at(ctx.location), "dropped where fuel's sold"); assert_eq!(ctx.hold.scrip, 0); assert!(ctx.hold.debt > 0); assert!(ctx.day > 0.0); assert!(!ctx.stranded()); // No tow for a ship that can buy fuel where it is. let mut map = GalaxyMap::new(&ctx); assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::None)); } #[test] fn arrows_move_between_stars() { let ctx = test_ctx(); let mut map = GalaxyMap::new(&ctx); let from = ctx.galaxy.sites[map.selected].pos; map.step(Vec2::new(1.0, 0.0), &ctx); if map.selected != ctx.star { assert!(ctx.galaxy.sites[map.selected].pos.x > from.x); } map.select(ctx.star, &ctx); assert_eq!(map.selected, ctx.star); } #[test] fn names_join_naturally() { let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); assert_eq!(join_names(&names(&["A"])), "A"); assert_eq!(join_names(&names(&["A", "B", "C"])), "A, B and C"); assert_eq!(plural(1, "jump"), "1 jump"); } }