summaryrefslogtreecommitdiff
path: root/src/scenes/port.rs
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-11 15:24:37 -0800
committerskylar <me@casper>2026-11-11 15:24:37 -0800
commit9ccf951473e21a8b90c25161156fa09a9b227a7b (patch)
treee7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/port.rs
parentd36c54d996f3c14ae811312dbbe621e12a39edde (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'src/scenes/port.rs')
-rw-r--r--src/scenes/port.rs330
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);
+ }
+}