diff options
Diffstat (limited to 'src/scenes/port.rs')
| -rw-r--r-- | src/scenes/port.rs | 330 |
1 files changed, 330 insertions, 0 deletions
diff --git a/src/scenes/port.rs b/src/scenes/port.rs new file mode 100644 index 0000000..5595822 --- /dev/null +++ b/src/scenes/port.rs @@ -0,0 +1,330 @@ +//! 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<String>, + colour: Color, + /// What each ore sells for here, if anyone's buying. + prices: Option<Vec<(&'static Ore, u32)>>, + /// What a jump's worth of fuel costs, at a station. + fuel_price: Option<u32>, + /// What happened at the market. + report: Option<String>, + 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<Pause>, +} + +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); + } +} |