From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/scenes/galaxy_map.rs | 929 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 929 insertions(+) create mode 100644 src/scenes/galaxy_map.rs (limited to 'src/scenes/galaxy_map.rs') diff --git a/src/scenes/galaxy_map.rs b/src/scenes/galaxy_map.rs new file mode 100644 index 0000000..5059b01 --- /dev/null +++ b/src/scenes/galaxy_map.rs @@ -0,0 +1,929 @@ +//! 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"); + } +} -- cgit v1.2.3