//! Docked at a station, or landed somewhere no one lives: what the place //! is like, and at a station, a market for the ore in your hold and the //! salvage you've brought back from wrecks. Worlds //! where people live have whole cities instead (`scenes::town`), which you //! walk out to through your ship; here there's no city, so landing brings //! you straight here, and leaving takes off again. use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout}; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; use super::character::Sheet; use super::keys::{self, ACCENT, DIM}; use super::pause::{Choice, Pause}; use super::sky; use super::{Scene, Transition}; use crate::app::{Ctx, Hold}; use crate::items; use crate::names; use crate::space::locale::Ore; use crate::space::system::{PlaceKind, System}; use crate::trade; /// The colour fuel is shown in, everywhere. pub const FUEL: Color = Color::Rgb(120, 210, 255); pub struct Port { title: String, /// "a desert world, home of the Qaari". subtitle: String, description: Vec, colour: Color, /// What each ore sells for here, if anyone's buying. prices: Option>, /// What a jump's worth of fuel costs, at a station. fuel_price: Option, /// What happened at the market. report: Option, place: usize, /// Whether this is a dock, which you undock from, or ground you take /// off from. docked: bool, /// The menu `esc` opens. pause: Option, } impl Port { pub fn new(ctx: &Ctx, place: usize) -> Port { let s = &ctx.system; let (title, subtitle, description, colour, people) = match s.places[place].kind { PlaceKind::Planet(p) => { let planet = &s.planets[p]; let home = if s.is_homeworld(p) { ", homeworld of " } else { ", settled by " }; let people = planet .people .map_or(", uninhabited".to_string(), |c| format!("{home}{}", c.name())); ( planet.name.clone(), format!("{}{people}", names::a(planet.kind.name)), planet.description.clone(), planet.kind.rgb, planet.people, ) } PlaceKind::Moon(p, m) => { let moon = &s.planets[p].moons[m]; let people = moon .people .map_or(", uninhabited".to_string(), |c| format!(", settled by {}", c.name())); let kind = moon.kind.name.trim_end_matches(" world"); let what = format!("{} moon of {}{people}", names::a(kind), s.planets[p].name); (moon.name.clone(), what, moon.description.clone(), moon.kind.rgb, moon.people) } PlaceKind::Station(i) => { let station = &s.stations[i]; let (_, colour) = sky::station_art(station.culture); let what = format!("a {} station", station.culture.adjective()); ( station.name.clone(), what, station.description.clone(), colour, Some(station.culture), ) } PlaceKind::Belt => ( s.belt.name.clone(), "an asteroid belt".to_string(), Vec::new(), (150, 138, 124), None, ), }; Port { title, subtitle, description, colour: sky::rgb(colour), prices: people.map(|_| trade::ore_prices(s, place)), fuel_price: s.sells_fuel_at(place).then(|| trade::fuel_price(s, place)), report: None, place, docked: matches!(s.places[place].kind, PlaceKind::Station(_)), pause: None, } } fn refuel(&mut self, hold: &mut Hold) { let Some(price) = self.fuel_price else { self.report = Some("No one here sells fuel.".to_string()); return; }; self.report = Some(match trade::buy_fuel(hold, price) { (0, _) if hold.fuel >= hold.fuel_capacity => "Your tank's already full.".to_string(), (0, _) => "You can't afford any fuel.".to_string(), (jumps, cost) => format!( "Bought {jumps} jump{} of fuel for {cost} ¤. Tank: {}/{}.", if jumps == 1 { "" } else { "s" }, hold.fuel, hold.fuel_capacity ), }); } /// Sells all your ore, and all your salvage, on you and aboard. fn sell(&mut self, hold: &mut Hold, system: &System) { let Some(prices) = &self.prices else { self.report = Some("No one lives here to buy anything.".to_string()); return; }; let debt = hold.debt; let ore = trade::sell_ore(hold, prices); let salvage = trade::sell_all_salvage(hold, system, self.place); let mut sold = Vec::new(); if let Some((tons, earned)) = ore { sold.push(format!("{tons} t of ore for {earned} ¤")); } if let Some((pieces, earned)) = salvage { let s = if pieces == 1 { "" } else { "s" }; sold.push(format!("{pieces} piece{s} of salvage for {earned} ¤")); } self.report = Some(if sold.is_empty() { "You've no ore or salvage to sell.".to_string() } else { format!("Sold {}.{}", sold.join(" and "), trade::debt_note(debt - hold.debt, hold)) }); } } impl Scene for Port { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { if let Some(pause) = &mut self.pause { let Some(choice) = pause.key(key) else { return Transition::None }; self.pause = None; return match choice { Choice::Quit => Transition::Title, Choice::Here => Transition::Pop, Choice::Resume => Transition::None, }; } match key.code { KeyCode::Esc => { let (here, note) = if self.docked { ("Undock", format!("Docked at {}.", self.title)) } else { ("Take off", format!("Landed on {}.", self.title)) }; self.pause = Some(Pause::new(Some(here), Some(note))); Transition::None } KeyCode::Char('s' | 'S') => { self.sell(&mut ctx.hold, &ctx.system); Transition::None } KeyCode::Char('f' | 'F') => { self.refuel(&mut ctx.hold); Transition::None } KeyCode::Char('c' | 'C') => Transition::Push(Box::new(Sheet::new())), // Only `l`, as it landed you: enter or space mashed through a // message shouldn't take off. KeyCode::Char('l' | 'L') => Transition::Pop, _ => Transition::None, } } fn render(&self, frame: &mut Frame, ctx: &Ctx) { let area = frame.area(); let [area] = Layout::horizontal([Constraint::Max(68)]).flex(Flex::Center).areas(area); let mut lines = vec![Line::from(self.subtitle.clone()).fg(DIM), Line::default()]; for sentence in &self.description { lines.push(Line::from(sentence.clone())); lines.push(Line::default()); } let hold = &ctx.hold; match &self.prices { Some(prices) => { lines.push(Line::from("THE MARKET").fg(ACCENT).bold()); for (ore, price) in prices { let have = hold.ore.get(ore.name).copied().unwrap_or(0); let have = if have > 0 { format!("you have {have} t") } else { String::new() }; lines.push(Line::from(vec![ Span::raw(" * ").fg(sky::rgb(ore.rgb)), Span::raw(format!("{:<16}{:>4} ¤ a ton ", ore.name, price)), Span::raw(have).fg(DIM), ])); } } None => lines.push(Line::from("No one lives here to trade with.").fg(DIM)), } if let Some(price) = self.fuel_price { lines.push(Line::from(vec![ Span::raw(" ◆ ").fg(FUEL), Span::raw(format!("{:<16}{:>4} ¤ a jump ", "fuel", price)), Span::raw(format!("tank {}/{}", hold.fuel, hold.fuel_capacity)).fg(DIM), ])); } lines.push(Line::default()); let salvage = if self.prices.is_some() { trade::salvage_kept(hold, &ctx.system, self.place) } else { Vec::new() }; if !salvage.is_empty() { let worth: u32 = salvage.iter().map(|(_, w)| w).sum(); lines.push(Line::from(vec![ Span::raw(" ( ").fg(Color::Rgb(150, 200, 222)), Span::raw(format!("{:<16}{:>4} ¤ the lot", "your salvage", worth)), ])); } lines.push(Line::from(format!( "Your hold: {} t of ore, {} artifacts, {} ¤. You carry {} of {} kg.", hold.ore_tons(), hold.artifacts(), hold.scrip, items::kg(hold.gear.carried()), items::kg(hold.gear.capacity) ))); if hold.debt > 0 { lines.push(Line::from(format!("You owe {} ¤ for a tow.", hold.debt)).fg(DIM)); } if let Some(report) = &self.report { lines.push(Line::from(report.clone()).fg(ACCENT)); } lines.push(Line::default()); let mut hints = vec![]; if self.prices.is_some() { hints.push(("s", "sell ore and salvage")); } if self.fuel_price.is_some() { hints.push(("f", "fill the tank")); } hints.push(("l", if self.docked { "undock" } else { "take off" })); hints.extend([("c", "character"), ("esc", "menu")]); lines.extend(keys::packed(&hints, area.width.saturating_sub(4))); let block = Block::bordered() .title(format!(" {} ", self.title)) .title_style(Style::new().fg(self.colour).bold()) .border_style(Style::new().fg(self.colour)) .padding(Padding::horizontal(1)); // As tall as the text, wrapped, and no taller. let width = usize::from(area.width.saturating_sub(4)).max(1); let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum(); let height = u16::try_from(rows + 3).unwrap_or(u16::MAX); let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); if let Some(pause) = &self.pause { pause.render(frame, frame.area()); } } } #[cfg(test)] mod tests { use super::*; use crate::scenes::tests::test_ctx; #[test] fn only_l_takes_off() { use ratatui::crossterm::event::KeyModifiers; let mut ctx = test_ctx(); let mut port = Port::new(&ctx, 0); let mut press = |code| port.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx); assert!(matches!(press(KeyCode::Enter), Transition::None)); assert!(matches!(press(KeyCode::Char(' ')), Transition::None)); assert!(matches!(press(KeyCode::Char('c')), Transition::Push(_))); assert!(matches!(press(KeyCode::Char('l')), Transition::Pop)); } #[test] fn stations_buy_ore() { let mut ctx = test_ctx(); let station = ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap(); let mut port = Port::new(&ctx, station); let prices = port.prices.clone().expect("stations trade"); ctx.hold.ore.insert("iron ore", 3); ctx.hold.ore.insert("iridium", 2); let salvage = crate::items::Salvage { title: "hull plate".into(), value: 20, mass: 9000 }; ctx.hold .lockers .push(crate::items::Stack::one(crate::items::Item::Salvage(salvage.clone()))); port.sell(&mut ctx.hold, &ctx.system); let scrap = trade::salvage_offer(&ctx.system, station, &salvage); assert!(ctx.hold.lockers.is_empty(), "the salvage goes too"); let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).unwrap().1; assert_eq!(ctx.hold.scrip, 3 * price("iron ore") + 2 * price("iridium") + scrap); assert_eq!(ctx.hold.ore_tons(), 0); } #[test] fn stations_sell_fuel() { let mut ctx = test_ctx(); let station = ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap(); let mut port = Port::new(&ctx, station); let price = port.fuel_price.expect("stations sell fuel"); ctx.hold.fuel = 1; ctx.hold.scrip = 10 * price; port.key(KeyEvent::from(KeyCode::Char('f')), &mut ctx); assert_eq!(ctx.hold.fuel, ctx.hold.fuel_capacity); assert_eq!(ctx.hold.scrip, 7 * price); } }