diff options
Diffstat (limited to 'src/scenes')
| -rw-r--r-- | src/scenes/aboard.rs | 1314 | ||||
| -rw-r--r-- | src/scenes/character.rs | 821 | ||||
| -rw-r--r-- | src/scenes/derelict.rs | 1117 | ||||
| -rw-r--r-- | src/scenes/flight.rs | 1163 | ||||
| -rw-r--r-- | src/scenes/forge.rs | 10 | ||||
| -rw-r--r-- | src/scenes/galaxy_map.rs | 929 | ||||
| -rw-r--r-- | src/scenes/inventory.rs | 1808 | ||||
| -rw-r--r-- | src/scenes/keys.rs | 97 | ||||
| -rw-r--r-- | src/scenes/mod.rs | 159 | ||||
| -rw-r--r-- | src/scenes/pause.rs | 160 | ||||
| -rw-r--r-- | src/scenes/port.rs | 330 | ||||
| -rw-r--r-- | src/scenes/saves.rs | 278 | ||||
| -rw-r--r-- | src/scenes/sky.rs | 460 | ||||
| -rw-r--r-- | src/scenes/system_map.rs | 830 | ||||
| -rw-r--r-- | src/scenes/title.rs | 178 | ||||
| -rw-r--r-- | src/scenes/town.rs | 2509 | ||||
| -rw-r--r-- | src/scenes/zz_dump.rs | 62 |
17 files changed, 12066 insertions, 159 deletions
diff --git a/src/scenes/aboard.rs b/src/scenes/aboard.rs new file mode 100644 index 0000000..49dc61d --- /dev/null +++ b/src/scenes/aboard.rs @@ -0,0 +1,1314 @@ +//! Aboard your own ship, on foot. +//! +//! Landing leaves you in the cockpit. To go ashore you walk aft through the +//! ship to the airlock in the stern, and to take off again you walk back +//! forward and sit in the pilot's seat. Out in space you can get up from +//! the controls whenever you're flying slowly: the ship holds station while +//! you walk about, and if a wreck is alongside, the airlock boards it. +//! +//! The ship is drawn by hand below, in the shape of the dart you fly: the +//! cockpit in the nose, an engine room in each swept-back wing, and the +//! airlock in the notch between them. The hold has a bin for every five +//! tons of ore you can carry, more as cargo racks are fitted, and shows +//! what's in them. The shelf in the crew quarters shows the artifacts in +//! your lockers, and the med cabinet the medpatches. +//! +//! `i` opens the inventory, with the lockers beside what you carry. What +//! you put down on the deck stays where you left it, for good. `<` walks +//! you forward to the pilot's seat and `>` aft to the airlock, stopping +//! beside them. + +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; + +use super::character::Sheet; +use super::derelict::{Derelict, camera}; +use super::inventory::{self, Beside, Inventory, Reply, View}; +use super::keys::{self, ACCENT, DIM}; +use super::pause::{Choice, Pause}; +use super::port::Port; +use super::sky::{mix, rgb}; +use super::town::TownScene; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold}; +use crate::dungeon::Pos; +use crate::items::{self, Item, Stack}; +use crate::space::locale::ORES; +use crate::space::system::{Landing, PlaceKind, Rgb, Wreck}; +use crate::trade; + +const PANEL_WIDTH: u16 = 32; +const LOG_HEIGHT: u16 = 6; +/// The most steps one run takes. +const RUN_LIMIT: usize = 60; +/// Tons of ore to a bin in the hold. +const BIN_TONS: u32 = 5; +/// The middle row. With the nose to the east, rows above it are the port +/// side and rows below it starboard. +const MIDSHIPS: i32 = 9; + +/// Your ship, nose to the east, one character to a cell: +/// +/// `#` hull, `x` bulkhead, `.` deck, `:` engine-room grating, `/` doorway, +/// `A` the airlock, `w` windscreen, `P` the pilot's seat, `c` crew seats, +/// `n` `N` `r` the flight, nav and comms consoles, `E` the drives, +/// `k` workbenches, `O` ore bins, `1` to `3` the bins each set of cargo +/// racks adds, `b` bunks, `B` the med-bay couch, `m` the med cabinet, +/// `*` the shelf for artifacts, `l` lockers, `C` crates, `t` the galley +/// table, `h` chairs, `s` the galley, `f` a fern. +#[rustfmt::skip] +const LAYOUT: [&str; 19] = [ + "##############", + "#:EE:::::::::#####", + "#:EE:::::k:::::::#####", + "#::::::::::::::::::::#", + "####xxx/xxxxxxxxxxxxx####################", + " #..............x.bb.bb.bb.**lxmm...BB#####", + " #.OOOO.11.22.33x.............x.......x...#####", + " #..............x.............x.......x.......#####", + " #..............xxxxxxx/xxxxxxxxxx/xxxx.......cNNN#www", + " A............../...................../..........Pnnnw", + " #..............xxxxxxx/xxxxxxxxxx/xxxx.......crrr#www", + " #..............x....hh.......x.......x.......#####", + " #.OOOO.11.22.33x...htth......x......Cx...#####", + " #..............xsss.........fxlllll.C#####", + "####xxx/xxxxxxxxxxxxx####################", + "#::::::::::::::::::::#", + "#:EE:::::k:::::::#####", + "#:EE:::::::::#####", + "##############", +]; + +const HEIGHT: i32 = LAYOUT.len() as i32; +const WIDTH: i32 = { + let mut width = 0; + let mut i = 0; + while i < LAYOUT.len() { + if LAYOUT[i].len() > width { + width = LAYOUT[i].len(); + } + i += 1; + } + width as i32 +}; + +/// A room: its name, and its top left and bottom right corners. +struct Room(&'static str, (i32, i32), (i32, i32)); + +/// The first room that holds a cell names it, so doorways go with the +/// corridor or the hold. +const ROOMS: [Room; 9] = [ + Room("the cockpit", (41, 6), (54, 12)), + Room("the corridor", (19, 8), (40, 10)), + Room("the crew quarters", (19, 5), (31, 7)), + Room("the galley", (19, 11), (31, 13)), + Room("the med-bay", (33, 5), (39, 7)), + Room("the stores", (33, 11), (39, 13)), + Room("the hold", (3, 4), (18, 14)), + Room("the port engine room", (1, 1), (20, 3)), + Room("the starboard engine room", (1, 15), (20, 17)), +]; + +/// Box-drawing glyphs for walls, by which neighbours they join: 1 north, +/// 2 east, 4 south, 8 west. The hull is drawn in double lines. +const DOUBLE: [char; 16] = + ['■', '║', '═', '╚', '║', '║', '╔', '╠', '═', '╝', '═', '╩', '╗', '╣', '╦', '╬']; +const SINGLE: [char; 16] = + ['■', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼']; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cell { + Void, + Hull, + Wall, + Deck, + Grating, + Door, + Airlock, + Window, + PilotSeat, + /// An ore bin: one the ship came with (set 0), or one of the `n`th set + /// of cargo racks, which is bare deck until they're fitted. + Bin(u8), + Fixture(Fixture), +} + +impl Cell { + fn walkable(self) -> bool { + matches!(self, Cell::Deck | Cell::Grating | Cell::Door) + } + + /// Whether a wall beside it joins up with it. + fn joins(self) -> bool { + matches!(self, Cell::Hull | Cell::Wall | Cell::Airlock | Cell::Window) + } +} + +/// Things you bump into to look at. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fixture { + FlightConsole, + NavConsole, + Comms, + CrewSeat, + Drive, + Workbench, + Bunk, + Couch, + MedCabinet, + Shelf, + Locker, + Crate, + Table, + Chair, + Galley, + Fern, +} + +fn parse(ch: u8) -> Option<Cell> { + use Fixture as F; + let fixture = match ch { + b' ' => return Some(Cell::Void), + b'#' => return Some(Cell::Hull), + b'x' => return Some(Cell::Wall), + b'.' => return Some(Cell::Deck), + b':' => return Some(Cell::Grating), + b'/' => return Some(Cell::Door), + b'A' => return Some(Cell::Airlock), + b'w' => return Some(Cell::Window), + b'P' => return Some(Cell::PilotSeat), + b'O' => return Some(Cell::Bin(0)), + b'1'..=b'3' => return Some(Cell::Bin(ch - b'0')), + b'n' => F::FlightConsole, + b'N' => F::NavConsole, + b'r' => F::Comms, + b'c' => F::CrewSeat, + b'E' => F::Drive, + b'k' => F::Workbench, + b'b' => F::Bunk, + b'B' => F::Couch, + b'm' => F::MedCabinet, + b'*' => F::Shelf, + b'l' => F::Locker, + b'C' => F::Crate, + b't' => F::Table, + b'h' => F::Chair, + b's' => F::Galley, + b'f' => F::Fern, + _ => return None, + }; + Some(Cell::Fixture(fixture)) +} + +/// The cell at `p` as drawn, before any cargo racks are fitted. Off the +/// map is `Void`. +fn layout(p: Pos) -> Cell { + let row = usize::try_from(p.y).ok().and_then(|y| LAYOUT.get(y)); + let ch = row.and_then(|row| usize::try_from(p.x).ok().and_then(|x| row.as_bytes().get(x))); + ch.and_then(|&ch| parse(ch)).unwrap_or(Cell::Void) +} + +/// Every cell drawn as `ch`, row by row. +fn positions(ch: u8) -> impl Iterator<Item = Pos> { + LAYOUT.iter().enumerate().flat_map(move |(y, row)| { + row.bytes() + .enumerate() + .filter(move |&(_, c)| c == ch) + .map(move |(x, _)| Pos::new(x as i32, y as i32)) + }) +} + +fn seat() -> Pos { + positions(b'P').next().expect("the ship has a pilot's seat") +} + +fn room_at(p: Pos) -> Option<&'static str> { + ROOMS + .iter() + .find(|Room(_, (x0, y0), (x1, y1))| { + (*x0..=*x1).contains(&p.x) && (*y0..=*y1).contains(&p.y) + }) + .map(|room| room.0) +} + +/// Every bin, with its set, in the order they fill: the ship's own, from +/// aft forward, then each set of racks in turn. +fn bins() -> Vec<(u8, Pos)> { + let mut bins: Vec<(u8, Pos)> = (0..HEIGHT) + .flat_map(|y| (0..WIDTH).map(move |x| Pos::new(x, y))) + .filter_map(|p| match layout(p) { + Cell::Bin(set) => Some((set, p)), + _ => None, + }) + .collect(); + bins.sort_by_key(|&(set, p)| (set, p.x, p.y)); + bins +} + +/// How many sets of cargo racks are fitted. +fn racks(hold: &Hold) -> u8 { + let extra = hold.ore_capacity.saturating_sub(Hold::BASE_ORE_CAPACITY); + u8::try_from(extra / trade::RACK_TONS).unwrap_or(u8::MAX) +} + +/// The ore in the first `count` bins: tons in each, and the colour of the +/// ore that fills most of it. +fn bin_contents(hold: &Hold, count: usize) -> Vec<(u32, Option<Rgb>)> { + let mut bins = vec![(0, None::<(Rgb, u32)>); count]; + let mut i = 0; + for (name, &tons) in &hold.ore { + let colour = ORES.iter().find(|o| o.name == *name).map_or((150, 138, 124), |o| o.rgb); + let mut left = tons; + while left > 0 && i < count { + let (filled, most) = &mut bins[i]; + let pour = left.min(BIN_TONS - *filled); + *filled += pour; + if most.is_none_or(|(_, t)| pour > t) { + *most = Some((colour, pour)); + } + left -= pour; + if *filled == BIN_TONS { + i += 1; + } + } + } + bins.into_iter().map(|(tons, most)| (tons, most.map(|(colour, _)| colour))).collect() +} + +/// Where the ship is while you walk about it. +#[derive(Clone, Debug, PartialEq)] +pub enum Berth { + /// Set down at a place: a city's landing field, a station's dock, or + /// open ground where no one lives. + Landed(usize), + /// Holding station out in space, with a wreck alongside if there's one. + Adrift(Option<Wreck>), +} + +/// Where you went through the airlock to. While you're there, its scene is +/// the one on top of this. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Outside { + City, + Port, + Wreck, +} + +pub struct Aboard { + berth: Berth, + /// Where the ship is, for the panel: "landed on Qeth", "holding station + /// in orbit around Qeth, a desert world". + whereabouts: String, + player: Pos, + log: Vec<String>, + /// How many things have been said, ever: the log itself stops growing. + said: usize, + outside: Option<Outside>, + bins: Vec<(u8, Pos)>, + shelf: Vec<Pos>, + /// Open over the ship when you're looking through what you carry, with + /// the lockers or the deck beside it. + inventory: Option<(Inventory, Beside)>, + /// The menu `esc` opens. + pause: Option<Pause>, +} + +impl Aboard { + /// Just landed or docked at `place`: you get up from the pilot's seat. + pub fn landed(ctx: &Ctx, place: usize) -> Aboard { + let name = &ctx.system.places[place].name; + let (whereabouts, arrival) = match ctx.system.landing(place) { + Landing::Capital(_) => ( + format!("landed on {name}"), + format!("You set down on {name}, on the landing field outside its capital."), + ), + Landing::Market(_) => (format!("docked at {name}"), format!("You dock at {name}.")), + _ => { + (format!("landed on {name}"), format!("You set down on {name}. No one lives here.")) + } + }; + let mut scene = Aboard::new(Berth::Landed(place), whereabouts); + scene.say(arrival); + scene.say("You unbuckle and get up. The airlock is aft, through the hold.".into()); + scene + } + + /// Back aboard, landed at `place`, in a game you've come back to. + pub fn resumed(ctx: &Ctx, place: usize) -> Aboard { + let mut scene = Aboard::landed(ctx, place); + scene.log.clear(); + let ship = ctx.character.ship_name(); + let name = &ctx.system.places[place].name; + let berth = match ctx.system.landing(place) { + Landing::Market(_) => format!("docked at {name}"), + _ => format!("landed on {name}"), + }; + scene.say(format!("You're aboard {ship}, {berth}, on day {:.0}.", ctx.day.floor())); + scene.say("The airlock is aft, through the hold: [>] walks you there.".into()); + scene + } + + /// Up from the controls out in space, where the ship is `what` ("in + /// orbit around Qeth, a desert world"), with `wreck` alongside if + /// there's one. + pub fn adrift(what: &str, wreck: Option<Wreck>) -> Aboard { + let alongside = wreck.as_ref().map(|w| capitalise(&w.name)); + let mut scene = Aboard::new(Berth::Adrift(wreck), format!("holding station {what}")); + scene.say("You set the ship to hold station, unbuckle and get up.".into()); + if let Some(name) = alongside { + scene.say(format!("{name} drifts alongside. The airlock is aft, through the hold.")); + } + scene + } + + fn new(berth: Berth, whereabouts: String) -> Aboard { + Aboard { + berth, + whereabouts, + player: seat().offset(-1, 0), + log: Vec::new(), + said: 0, + outside: None, + bins: bins(), + shelf: positions(b'*').collect(), + inventory: None, + pause: None, + } + } + + fn say(&mut self, text: String) { + self.log.push(text); + self.said += 1; + if self.log.len() > 50 { + self.log.remove(0); + } + } + + /// The cell at `p`, with racks that aren't fitted left out. + fn cell(&self, p: Pos, hold: &Hold) -> Cell { + match layout(p) { + Cell::Bin(set) if set > racks(hold) => Cell::Deck, + cell => cell, + } + } + + /// Back in through the airlock, if you were out. + fn come_back(&mut self) { + let Some(outside) = self.outside.take() else { return }; + let text = match outside { + Outside::City => "You climb the ramp and seal the airlock behind you.", + Outside::Port => "You come back aboard and seal the airlock.", + Outside::Wreck => "The airlock cycles, and you're back aboard your own ship.", + }; + self.say(text.into()); + } + + fn step(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { + let to = self.player.offset(dx, dy); + match self.cell(to, &ctx.hold) { + cell if cell.walkable() => { + self.player = to; + if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, to)) { + self.say(text); + } + } + Cell::PilotSeat => return Transition::Pop, + Cell::Airlock => return self.airlock(ctx), + Cell::Window => { + let text = self.view_out(ctx); + self.say(text); + } + Cell::Bin(_) => self.say(hold_text(&ctx.hold)), + // The weapons locker opens the lockers. + Cell::Fixture(Fixture::Locker) if to.y < MIDSHIPS => { + self.say("You open the lockers.".into()); + self.inventory = Some((Inventory::new(), Beside::Lockers)); + } + Cell::Fixture(fixture) => { + let text = self.about(fixture, to, ctx); + self.say(text); + } + _ => {} + } + Transition::None + } + + /// `g`: picks up what's on the deck where you stand, or lists it if + /// there's more than one thing. + fn pick_up(&mut self, ctx: &mut Ctx) { + let here = inventory::lying(&ctx.hold.deck, self.player); + match &here[..] { + [] => self.say("There's nothing here to pick up.".into()), + [stack] => { + let command = inventory::Command::Fetch(0, stack.count); + let text = self.carry_out(command, Beside::Floor("on the deck"), ctx); + self.say(text); + } + _ => self.inventory = Some((Inventory::beside(), Beside::Floor("on the deck"))), + } + } + + /// Does what the inventory asks, with the lockers or the deck beside you. + fn carry_out(&mut self, command: inventory::Command, beside: Beside, ctx: &mut Ctx) -> String { + let h = &mut ctx.hold; + let mut floor = inventory::gather(&mut h.deck, self.player); + let lockers = (beside == Beside::Lockers).then_some(&mut h.lockers); + let text = inventory::carry_out(command, &mut h.gear, lockers, &mut floor, Some(beside)); + inventory::scatter(&mut h.deck, self.player, floor); + text + } + + /// A key while the inventory's open. + fn inventory_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { + let Some((inv, beside)) = &mut self.inventory else { return }; + let beside = *beside; + let floor = inventory::lying(&ctx.hold.deck, self.player); + let view = view(ctx, beside, &floor); + match inv.key(key, &view) { + Reply::Stay => {} + Reply::Close => self.inventory = None, + Reply::Sort(sort) => ctx.inventory_sort = sort, + Reply::Do(command) => { + let text = self.carry_out(command, beside, ctx); + if let Some((inv, _)) = &mut self.inventory { + inv.note = Some(text.clone()); + } + self.say(text); + } + } + } + + /// Steps until something changes: a wall, a doorway into another room, + /// something said. + fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { + for _ in 0..RUN_LIMIT { + let (before, room, said) = (self.player, room_at(self.player), self.said); + let transition = self.step(dx, dy, ctx); + if !matches!(transition, Transition::None) { + return transition; + } + if self.player == before || self.said != said || room_at(self.player) != room { + break; + } + } + Transition::None + } + + /// Out through the airlock: into the city, the station or the wreck + /// alongside, if there's anywhere to go. + fn airlock(&mut self, ctx: &mut Ctx) -> Transition { + match self.berth { + Berth::Landed(place) => { + if matches!(ctx.system.landing(place), Landing::Capital(_)) { + self.outside = Some(Outside::City); + Transition::Push(Box::new(TownScene::new(ctx, place))) + } else { + self.outside = Some(Outside::Port); + Transition::Push(Box::new(Port::new(ctx, place))) + } + } + Berth::Adrift(None) => { + self.say( + "There's nothing outside the airlock but vacuum. Bring the ship alongside \ + a wreck to board it." + .into(), + ); + Transition::None + } + Berth::Adrift(Some(ref wreck)) => { + let wreck = wreck.clone(); + if ctx.stripped.contains(&wreck.seed) { + let text = + format!("You've already stripped {}. Nothing's left aboard.", wreck.name); + self.say(text); + Transition::None + } else { + let seed = wreck.seed; + ctx.stripped.insert(seed); + self.outside = Some(Outside::Wreck); + Transition::Push(Box::new(Derelict::board(seed, ctx))) + } + } + } + } + + /// `<` or `>`: walks to stand beside the pilot's seat or the airlock, + /// by the shortest way, so one more step takes off or goes out. + fn walk_to(&mut self, goal: Cell, ctx: &Ctx) { + let (what, way) = match goal { + Cell::PilotSeat => ("the pilot's seat ╥", "forward"), + _ => ("the airlock Θ", "aft"), + }; + let beside = |p: Pos| { + [(0, -1), (1, 0), (0, 1), (-1, 0)] + .into_iter() + .any(|(dx, dy)| layout(p.offset(dx, dy)) == goal) + }; + if beside(self.player) { + self.say(format!("You're beside {what}.")); + return; + } + // Breadth first, the way you walk: a step in any of eight directions. + let mut seen = std::collections::HashSet::from([self.player]); + let mut queue = std::collections::VecDeque::from([self.player]); + while let Some(p) = queue.pop_front() { + if beside(p) { + self.player = p; + self.say(format!("You walk {way} to {what}.")); + if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, p)) { + self.say(text); + } + return; + } + for dy in -1..=1 { + for dx in -1..=1 { + let next = p.offset(dx, dy); + if self.cell(next, &ctx.hold).walkable() && seen.insert(next) { + queue.push_back(next); + } + } + } + } + self.say(format!("There's no way through to {what}.")); + } + + /// Esc: which way to go to leave. + fn way_out(&self) -> String { + match self.berth { + Berth::Landed(_) => "To take off, sit in the pilot's seat ╥, forward in the cockpit \ + ([<] walks you there). To go out, it's the airlock Θ, aft ([>])." + .into(), + Berth::Adrift(_) => "To fly on, sit in the pilot's seat ╥, forward in the cockpit \ + ([<] walks you there)." + .into(), + } + } + + fn view_out(&self, ctx: &Ctx) -> String { + match self.berth { + Berth::Landed(place) => { + let name = &ctx.system.places[place].name; + match ctx.system.landing(place) { + Landing::Capital(_) => format!( + "Through the windscreen: the landing field, and beyond it the walls of \ + {name}'s capital." + ), + Landing::Market(_) => { + format!( + "Through the windscreen: the docking bay of {name}, and dockhands at work." + ) + } + _ => format!("Through the windscreen: {name}, empty as far as you can see."), + } + } + Berth::Adrift(_) => { + format!("Through the windscreen: the stars. The ship is {}.", self.whereabouts) + } + } + } + + /// What you see when you bump `fixture`, at `at`. + fn about(&self, fixture: Fixture, at: Pos, ctx: &Ctx) -> String { + let h = &ctx.hold; + let port = at.y < MIDSHIPS; + match fixture { + Fixture::FlightConsole => "The flight console. Sit in the pilot's seat to fly.".into(), + Fixture::NavConsole => { + format!("The nav console: the {} system, day {:.1}.", ctx.system.name, ctx.day) + } + Fixture::Comms => "The comms board. Nothing on the open channels but static.".into(), + Fixture::CrewSeat if port => "The co-pilot's seat, with a jacket slung over it.".into(), + Fixture::CrewSeat => "The engineer's seat, worn smooth.".into(), + Fixture::Drive => { + let side = if port { "port" } else { "starboard" }; + match self.berth { + Berth::Landed(_) => format!("The {side} drive, cold and ticking as it cools."), + Berth::Adrift(_) => { + format!("The {side} drive, idling, holding the ship on station.") + } + } + } + Fixture::Workbench => { + "A workbench, with tools clipped to the bulkhead above it.".into() + } + Fixture::Bunk => "A bunk, made up tight.".into(), + Fixture::Couch => "The med-bay couch, its straps buckled down.".into(), + Fixture::MedCabinet => match stored(h, |i| matches!(i, Item::Medpatch)).count() { + 0 => "The med cabinet is empty. Doctors in the cities sell medpatches.".into(), + 1 => "The med cabinet holds a medpatch. Pack it (i) to take it aboard a wreck." + .into(), + n => format!( + "The med cabinet holds {n} medpatches. Pack them (i) to take them aboard \ + a wreck." + ), + }, + Fixture::Shelf => { + let names: Vec<String> = + stored(h, |i| matches!(i, Item::Artifact(_))).map(|i| i.name()).collect(); + if names.is_empty() { + "An empty shelf, for the strange things you find aboard wrecks.".into() + } else { + format!("On the shelf: {}.", listed(&names)) + } + } + Fixture::Locker => "Lockers of spare parts, coolant and air filters.".into(), + Fixture::Crate => "A crate of ration bricks, lashed to the deck.".into(), + Fixture::Table => "The galley table, bolted to the deck.".into(), + Fixture::Chair => "A chair, bolted down in case the gravity goes.".into(), + Fixture::Galley => "The galley: a hotplate, a kettle and a water still.".into(), + Fixture::Fern => "A potted fern. Against the odds, it's thriving.".into(), + } + } +} + +/// One of each thing in the lockers that's `kind`, counting stacks out. +fn stored(h: &Hold, kind: impl Fn(&Item) -> bool) -> impl Iterator<Item = &Item> { + h.lockers + .iter() + .filter(move |s| kind(&s.item)) + .flat_map(|s| std::iter::repeat_n(&s.item, s.count as usize)) +} + +/// What the inventory's shown: what you carry, beside the lockers or the +/// deck where you stand (`floor`). +fn view<'a>(ctx: &'a Ctx, beside: Beside, floor: &'a [Stack]) -> View<'a> { + let pile = match beside { + Beside::Lockers => &ctx.hold.lockers[..], + Beside::Floor(_) | Beside::Rack => floor, + }; + View { + gear: &ctx.hold.gear, + beside: (beside == Beside::Lockers || !pile.is_empty()).then_some((beside, pile)), + known: &ctx.bestiary, + hurt: false, + shop: None, + sort: ctx.inventory_sort, + } +} + +/// What the ore bins hold. +fn hold_text(h: &Hold) -> String { + let tons = h.ore_tons(); + if tons == 0 { + return format!( + "The ore bins are empty. They take {} t; the mining laser fills them.", + h.ore_capacity + ); + } + let kinds: Vec<String> = + h.ore.iter().filter(|&(_, &t)| t > 0).map(|(name, t)| format!("{t} t of {name}")).collect(); + format!("The ore bins hold {}: {tons} of {} t.", listed(&kinds), h.ore_capacity) +} + +/// "a, b and c". +fn listed(items: &[String]) -> String { + match items { + [] => 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()) +} + +impl Scene for Aboard { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + self.come_back(); + 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 | Choice::Resume => Transition::None, + }; + } + if self.inventory.is_some() { + self.inventory_key(key, ctx); + return Transition::None; + } + match key.code { + KeyCode::Char('c' | 'C') => return Transition::Push(Box::new(Sheet::new())), + KeyCode::Char('i') => { + self.inventory = Some((Inventory::new(), Beside::Lockers)); + return Transition::None; + } + KeyCode::Char('g' | ',') => { + self.pick_up(ctx); + return Transition::None; + } + KeyCode::Char('<') => { + self.walk_to(Cell::PilotSeat, ctx); + return Transition::None; + } + KeyCode::Char('>') => { + self.walk_to(Cell::Airlock, ctx); + return Transition::None; + } + KeyCode::Esc => { + self.pause = Some(Pause::new(None, Some(self.way_out()))); + return Transition::None; + } + _ => {} + } + match keys::movement(key) { + Some((dx, dy, true)) => self.run(dx, dy, ctx), + Some((dx, dy, false)) => self.step(dx, dy, ctx), + None => Transition::None, + } + } + + fn tick(&mut self, _ctx: &mut Ctx) -> Transition { + self.come_back(); + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [main, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(frame.area()); + let [map, log] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); + self.draw_map(frame.buffer_mut(), map, ctx); + self.draw_log(frame, log); + self.draw_panel(frame, panel, ctx); + if let Some((inv, beside)) = &self.inventory { + let floor = inventory::lying(&ctx.hold.deck, self.player); + inv.render(frame, frame.area(), &view(ctx, *beside, &floor)); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); + } + } +} + +/// A wall's glyph, joined up with the walls around it. +fn wall_glyph(p: Pos, glyphs: &[char; 16]) -> char { + let joins = |dx, dy| usize::from(layout(p.offset(dx, dy)).joins()); + glyphs[joins(0, -1) | joins(1, 0) << 1 | joins(0, 1) << 2 | joins(-1, 0) << 3] +} + +/// A console's lights, a few of them off at any moment. +fn lights(p: Pos, ticks: u64, on: Rgb) -> Rgb { + let k = (ticks / 10) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 3; + if k.rem_euclid(5) == 0 { mix(on, (0, 0, 0), 0.55) } else { on } +} + +fn bin_look((tons, ore): (u32, Option<Rgb>)) -> (char, Rgb) { + match ore { + Some(colour) if tons >= BIN_TONS => ('■', colour), + Some(colour) if tons > 0 => ('▪', colour), + _ => ('□', (96, 100, 116)), + } +} + +/// What's out of the windscreen when you're down: the ground, or the dock. +fn outside_rgb(ctx: &Ctx, place: usize) -> Rgb { + let s = &ctx.system; + match s.places[place].kind { + PlaceKind::Planet(p) => s.planets[p].kind.rgb, + PlaceKind::Moon(p, m) => s.planets[p].moons[m].kind.rgb, + PlaceKind::Station(_) | PlaceKind::Belt => (150, 160, 180), + } +} + +impl Aboard { + fn draw_map(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let left = camera(self.player.x, WIDTH, i32::from(area.width)); + let top = camera(self.player.y, HEIGHT, i32::from(area.height)); + let racks = racks(&ctx.hold); + let bins: Vec<Pos> = + self.bins.iter().filter(|&&(set, _)| set <= racks).map(|&(_, p)| p).collect(); + let contents = bin_contents(&ctx.hold, bins.len()); + for sy in 0..area.height { + for sx in 0..area.width { + let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); + let (glyph, colour, bold) = if p == self.player { + ('@', (255, 255, 255), true) + } else { + self.look(p, ctx, &bins, &contents) + }; + let cell = &mut buf[(area.x + sx, area.y + sy)]; + cell.set_char(glyph).set_fg(rgb(colour)); + if bold { + cell.modifier.insert(Modifier::BOLD); + } + } + } + } + + /// How the cell at `p` looks, leaving out you. + fn look( + &self, + p: Pos, + ctx: &Ctx, + bins: &[Pos], + contents: &[(u32, Option<Rgb>)], + ) -> (char, Rgb, bool) { + let ticks = ctx.ticks; + // What's put down on the deck, the last thing on top. + if let Some((_, stack)) = ctx.hold.deck.iter().rev().find(|(at, _)| *at == p) { + let (glyph, colour) = inventory::look(&stack.item); + return (glyph, colour, false); + } + let (glyph, colour) = match self.cell(p, &ctx.hold) { + Cell::Void => (' ', (0, 0, 0)), + Cell::Hull => (wall_glyph(p, &DOUBLE), (122, 134, 160)), + Cell::Wall => (wall_glyph(p, &SINGLE), (150, 150, 162)), + // Where racks would be bolted, if you bought them. + Cell::Deck if matches!(layout(p), Cell::Bin(_)) => ('·', (84, 88, 104)), + Cell::Deck => ('.', (100, 104, 120)), + Cell::Grating => (':', (84, 88, 104)), + Cell::Door => ('\'', (216, 170, 86)), + Cell::Airlock => return ('Θ', (90, 222, 200), true), + Cell::PilotSeat => return ('╥', (240, 200, 110), true), + Cell::Window => match self.berth { + Berth::Landed(place) => ('▒', mix(outside_rgb(ctx, place), (170, 200, 230), 0.3)), + Berth::Adrift(_) => { + let twinkle = (ticks / 20) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 11; + if twinkle.rem_euclid(13) == 0 { + ('·', (220, 226, 255)) + } else { + ('░', (44, 52, 96)) + } + } + }, + Cell::Bin(_) => { + let i = bins.iter().position(|&b| b == p); + bin_look(i.and_then(|i| contents.get(i).copied()).unwrap_or((0, None))) + } + Cell::Fixture(fixture) => self.fixture_look(fixture, p, ctx), + }; + (glyph, colour, false) + } + + fn fixture_look(&self, fixture: Fixture, p: Pos, ctx: &Ctx) -> (char, Rgb) { + let ticks = ctx.ticks; + let h = &ctx.hold; + match fixture { + Fixture::FlightConsole => ('▤', lights(p, ticks, (110, 220, 150))), + Fixture::NavConsole => ('▤', lights(p, ticks, (110, 180, 240))), + Fixture::Comms => ('▤', lights(p, ticks, (230, 170, 90))), + Fixture::CrewSeat => ('╥', (150, 140, 124)), + Fixture::Drive => match self.berth { + Berth::Landed(_) => ('¤', (96, 104, 130)), + Berth::Adrift(_) => { + let k = (ticks as f64 / 6.0 + f64::from(p.x + p.y)).sin() * 0.5 + 0.5; + ('¤', mix((80, 110, 210), (190, 220, 255), k)) + } + }, + Fixture::Workbench => ('╦', (150, 156, 172)), + Fixture::Bunk => ('▬', (120, 140, 190)), + Fixture::Couch => ('▬', (210, 214, 226)), + Fixture::MedCabinet if stored(h, |i| matches!(i, Item::Medpatch)).next().is_some() => { + ('+', (240, 96, 96)) + } + Fixture::MedCabinet => ('+', (120, 64, 64)), + Fixture::Shelf => { + let i = self.shelf.iter().position(|&s| s == p).unwrap_or(0); + if i < stored(h, |i| matches!(i, Item::Artifact(_))).count() { + ('*', (255, 214, 92)) + } else { + ('≡', (130, 112, 84)) + } + } + Fixture::Locker => ('▪', (130, 140, 160)), + Fixture::Crate => ('■', (170, 140, 96)), + Fixture::Table => ('╤', (150, 156, 172)), + Fixture::Chair => ('╥', (120, 124, 140)), + Fixture::Galley => ('◘', (170, 176, 190)), + Fixture::Fern => ('♣', (90, 170, 80)), + } + } + + fn draw_log(&self, frame: &mut Frame, area: Rect) { + let shown = usize::from(area.height.saturating_sub(1)); + let first = self.log.len().saturating_sub(shown); + let lines: Vec<Line> = self.log[first..] + .iter() + .enumerate() + .map(|(i, text)| { + let age = self.log.len() - first - 1 - i; + let line = Line::from(text.as_str()); + match age { + 0 => line.white(), + 1 | 2 => line.gray(), + _ => line.dark_gray(), + } + }) + .collect(); + let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray()); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(format!(" {} ", ctx.character.ship_name())).bold().fg(ACCENT)) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let (seat, airlock) = match self.berth { + Berth::Landed(place) => ( + "pilot's seat ╥: take off", + match ctx.system.landing(place) { + Landing::Market(_) => "airlock Θ: to the station", + _ => "airlock Θ: go out", + }, + ), + Berth::Adrift(Some(_)) => ("pilot's seat ╥: fly on", "airlock Θ: board the wreck"), + Berth::Adrift(None) => ("pilot's seat ╥: fly on", "airlock Θ: only vacuum"), + }; + let hints = keys::packed( + &[ + ("arrows hjkl yubn", "walk"), + ("shift", "run"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("bump", "look at things"), + ("<", seat), + (">", airlock), + ("esc", "menu: save and quit"), + ], + inner.width, + ); + let [info, help] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(hints.len() as u16 + 1)]) + .areas(inner); + + let h = &ctx.hold; + let label = |text: &'static str| Span::raw(text).fg(DIM); + let room = room_at(self.player).unwrap_or("aboard"); + let mut lines = vec![ + Line::from("your ship").fg(DIM), + Line::from(self.whereabouts.clone()).fg(DIM), + Line::default(), + Line::from(capitalise(room)).fg(ACCENT), + ]; + if room != "the cockpit" { + lines.push(Line::from(" the cockpit is forward").fg(DIM)); + } + let somewhere_to_go = !matches!(self.berth, Berth::Adrift(None)); + if room != "the hold" && somewhere_to_go { + lines.push(Line::from(" the airlock is aft").fg(DIM)); + } + lines.extend([ + Line::default(), + Line::from(vec![label("money "), Span::raw(format!("{} ¤", h.scrip))]), + Line::from(vec![ + label("ore "), + Span::raw(format!("{}/{} t", h.ore_tons(), h.ore_capacity)), + ]), + Line::from(vec![ + label("fuel "), + Span::raw(format!("{}/{} jumps", h.fuel, h.fuel_capacity)), + ]), + Line::from(vec![label("lenses "), Span::raw(ctx.lenses.to_string())]), + Line::from(vec![ + label("carrying "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.gear.carried()), + items::kg(h.gear.capacity) + )), + ]), + Line::from(vec![ + label("lockers "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.locker_mass()), + items::kg(items::LOCKER_CAPACITY) + )), + ]), + Line::from(vec![label("medpatches "), Span::raw(h.medpatches().to_string())]), + ]); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + + // A line between what's here and the keys. + let [_, help] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(help); + frame.render_widget(Paragraph::new(hints), help); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + use std::collections::{HashMap, VecDeque}; + + use super::*; + use crate::names; + use crate::scenes::tests::test_ctx; + use crate::seed; + + fn press(scene: &mut Aboard, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn key_towards(dx: i32, dy: i32) -> KeyCode { + match (dx, dy) { + (-1, 0) => KeyCode::Left, + (1, 0) => KeyCode::Right, + (0, -1) => KeyCode::Up, + (0, 1) => KeyCode::Down, + (-1, -1) => KeyCode::Home, + (1, -1) => KeyCode::PageUp, + (-1, 1) => KeyCode::End, + _ => KeyCode::PageDown, + } + } + + /// The steps from `from` to every cell you can walk to, as a way back: + /// each cell's previous one. + fn paths(scene: &Aboard, hold: &Hold, from: Pos) -> HashMap<Pos, Pos> { + let mut came_from = HashMap::from([(from, from)]); + let mut queue = VecDeque::from([from]); + while let Some(p) = queue.pop_front() { + for n in p.neighbours() { + if scene.cell(n, hold).walkable() && !came_from.contains_key(&n) { + came_from.insert(n, p); + queue.push_back(n); + } + } + } + came_from + } + + /// Walks, one key at a time, to `to`, and then a step on towards + /// `bump`, returning what that last step did. + fn walk(scene: &mut Aboard, ctx: &mut Ctx, to: Pos, bump: Pos) -> Transition { + let came_from = paths(scene, &ctx.hold, scene.player); + let mut route = vec![to]; + while let Some(&before) = came_from.get(route.last().unwrap()) + && before != scene.player + { + route.push(before); + } + assert!(came_from.contains_key(&to), "can't walk to {to:?}"); + for &p in route.iter().rev() { + let (dx, dy) = (p.x - scene.player.x, p.y - scene.player.y); + assert!(matches!(press(scene, ctx, key_towards(dx, dy)), Transition::None)); + assert_eq!(scene.player, p); + } + press(scene, ctx, key_towards(bump.x - to.x, bump.y - to.y)) + } + + fn airlock() -> Pos { + positions(b'A').next().unwrap() + } + + #[test] + fn the_layout_is_whole() { + for row in LAYOUT { + for ch in row.bytes() { + assert!(parse(ch).is_some(), "{:?} isn't a cell", ch as char); + } + } + assert_eq!(positions(b'P').count(), 1); + assert_eq!(positions(b'A').count(), 1); + // Everything walkable is in a room, and you can get to all of it, + // and to everything you can bump, from the pilot's seat. + let mut ctx = test_ctx(); + ctx.hold.ore_capacity = Hold::BASE_ORE_CAPACITY; + let scene = Aboard::adrift("nowhere", None); + let reached = paths(&scene, &ctx.hold, scene.player); + for y in 0..HEIGHT { + for x in 0..WIDTH { + let p = Pos::new(x, y); + let cell = scene.cell(p, &ctx.hold); + if cell.walkable() { + assert!(reached.contains_key(&p), "can't reach {p:?}"); + assert!(room_at(p).is_some(), "{p:?} isn't in a room"); + } + // The flight console is in front of the pilot's seat, out of + // reach unless you're sitting in it. + let bumpable = matches!(cell, Cell::Fixture(_) | Cell::Bin(_) | Cell::Airlock) + && cell != Cell::Fixture(Fixture::FlightConsole); + if bumpable || cell == Cell::PilotSeat { + assert!(p.neighbours().any(|n| reached.contains_key(&n)), "can't reach {p:?}"); + } + } + } + } + + #[test] + fn there_is_a_bin_for_every_five_tons() { + let mut hold = Hold::default(); + let all = bins(); + for _ in 0..4 { + let racks = racks(&hold); + let count = all.iter().filter(|&&(set, _)| set <= racks).count() as u32; + assert_eq!(count * BIN_TONS, hold.ore_capacity); + hold.ore_capacity += trade::RACK_TONS; + } + hold.ore_capacity = Hold::BASE_ORE_CAPACITY; + hold.ore.insert("nickel", 12); + hold.ore.insert("iridium", 3); + let contents = bin_contents(&hold, 8); + let tons: Vec<u32> = contents.iter().map(|c| c.0).collect(); + assert_eq!(tons, [5, 5, 5, 0, 0, 0, 0, 0]); + let colour = |name: &str| ORES.iter().find(|o| o.name == name).unwrap().rgb; + // Ores go in by name: the first bin has 3 t of iridium in it, and + // 2 t of nickel on top. + assert_eq!(contents[0].1, Some(colour("iridium"))); + assert_eq!(contents[2].1, Some(colour("nickel"))); + assert_eq!(contents[3].1, None); + } + + #[test] + fn you_walk_aft_to_go_ashore_and_forward_to_take_off() { + let mut ctx = test_ctx(); + let home = ctx.system.home_place(); + let mut scene = Aboard::landed(&ctx, home); + assert_eq!(room_at(scene.player), Some("the cockpit")); + let door = airlock().offset(1, 0); + let out = walk(&mut scene, &mut ctx, door, airlock()); + assert!(matches!(out, Transition::Push(_)), "the airlock opens on the city"); + assert_eq!(scene.outside, Some(Outside::City)); + // Back from the city, you're just inside the airlock. + scene.tick(&mut ctx); + assert!(scene.log.last().unwrap().contains("seal the airlock")); + assert_eq!(scene.player, door); + let stand = seat().offset(-1, 0); + assert!(matches!(walk(&mut scene, &mut ctx, stand, seat()), Transition::Pop)); + } + + #[test] + fn angle_brackets_walk_you_to_the_airlock_and_the_seat() { + let mut ctx = test_ctx(); + let home = ctx.system.home_place(); + let mut scene = Aboard::landed(&ctx, home); + press(&mut scene, &mut ctx, KeyCode::Char('>')); + assert_eq!(layout(scene.player.offset(-1, 0)), Cell::Airlock, "beside it, not through"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('h')), Transition::Push(_))); + press(&mut scene, &mut ctx, KeyCode::Char('<')); + assert_eq!(layout(scene.player.offset(1, 0)), Cell::PilotSeat); + let said = scene.said; + press(&mut scene, &mut ctx, KeyCode::Char('<')); + assert_eq!(scene.said, said + 1, "you're told you're there already"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Pop)); + } + + #[test] + fn running_stops_at_doorways() { + let mut ctx = test_ctx(); + let mut scene = Aboard::adrift("nowhere", None); + let run = |scene: &mut Aboard, ctx: &mut Ctx| { + scene.key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), ctx) + }; + run(&mut scene, &mut ctx); + assert_eq!(scene.player, Pos::new(40, MIDSHIPS), "out of the cockpit"); + run(&mut scene, &mut ctx); + assert_eq!(scene.player, Pos::new(18, MIDSHIPS), "down the corridor"); + assert!(matches!(run(&mut scene, &mut ctx), Transition::None)); + assert_eq!(scene.player, airlock().offset(1, 0), "across the hold, to vacuum"); + assert!(scene.log.last().unwrap().contains("vacuum")); + } + + #[test] + fn the_airlock_boards_a_wreck_alongside_once() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + let mut scene = Aboard::adrift("nowhere", Some(wreck.clone())); + scene.player = airlock().offset(1, 0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::Push(_))); + assert!(ctx.stripped.contains(&wreck.seed)); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::None)); + assert!(scene.log.last().unwrap().contains("already stripped")); + } + + #[test] + fn the_hold_and_the_shelf_show_what_you_carry() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let shelf = positions(b'*').next().unwrap(); + scene.player = shelf.offset(0, 1); + press(&mut scene, &mut ctx, KeyCode::Up); + assert!(scene.log.last().unwrap().starts_with("An empty shelf")); + let artifact = names::artifact(&mut seed::rng(4)); + let name = artifact.name().to_string(); + ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); + press(&mut scene, &mut ctx, KeyCode::Up); + assert_eq!(scene.log.last().unwrap(), &format!("On the shelf: {name}.")); + + ctx.hold.ore.insert("nickel", 7); + let (_, bin) = bins()[0]; + scene.player = bin.offset(0, 1); + press(&mut scene, &mut ctx, KeyCode::Up); + assert_eq!(scene.log.last().unwrap(), "The ore bins hold 7 t of nickel: 7 of 40 t."); + // Racks you haven't bought are bare deck you can walk over. + let (_, rack) = *bins().iter().find(|&&(set, _)| set == 1).unwrap(); + assert!(scene.cell(rack, &ctx.hold).walkable()); + ctx.hold.ore_capacity += trade::RACK_TONS; + assert_eq!(scene.cell(rack, &ctx.hold), Cell::Bin(1)); + } + + #[test] + fn what_you_put_down_on_the_deck_stays_there() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let wire = + Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 9, mass: 2000 }); + ctx.hold.gear.add(Stack { item: wire.clone(), count: 3 }); + let start = scene.player; + + press(&mut scene, &mut ctx, KeyCode::Char('i')); + assert!(scene.inventory.is_some()); + let shown = screen(&scene, &ctx); + assert!(shown.contains("YOU ·") && shown.contains("LOCKERS"), "{shown}"); + for c in "/wire".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Enter); + for c in "2d".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Char('m')); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.inventory.is_some(), "esc clears what you were finding first"); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.inventory.is_none()); + assert_eq!( + inventory::lying(&ctx.hold.deck, start), + [Stack { item: wire.clone(), count: 2 }] + ); + assert_eq!(ctx.hold.lockers, [Stack::one(wire.clone())]); + assert!(ctx.hold.gear.pack.iter().all(|s| s.item != wire)); + + // Walk off and it's on the deck; walk back on and you're told. + press(&mut scene, &mut ctx, KeyCode::Char('h')); + assert_ne!(scene.player, start); + let (glyph, _, _) = scene.look(start, &ctx, &[], &[]); + assert_eq!(glyph, '('); + press(&mut scene, &mut ctx, KeyCode::Char('l')); + assert!(scene.log.last().unwrap().contains("2 coils of wire here")); + press(&mut scene, &mut ctx, KeyCode::Char('g')); + assert!(ctx.hold.deck.is_empty()); + assert!(ctx.hold.gear.pack.contains(&Stack { item: wire, count: 2 })); + } + + #[test] + fn the_weapons_locker_opens_the_lockers() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let locker = positions(b'l').find(|p| p.y < MIDSHIPS).unwrap(); + let from = locker.neighbours().find(|&n| scene.cell(n, &ctx.hold).walkable()).unwrap(); + scene.player = from; + press(&mut scene, &mut ctx, key_towards(locker.x - from.x, locker.y - from.y)); + assert!(matches!(scene.inventory, Some((_, Beside::Lockers)))); + } + + fn screen(scene: &Aboard, ctx: &Ctx) -> String { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() + } +} diff --git a/src/scenes/character.rs b/src/scenes/character.rs new file mode 100644 index 0000000..1ed2b11 --- /dev/null +++ b/src/scenes/character.rs @@ -0,0 +1,821 @@ +//! Characters: making a new one, and the sheet that shows who you are and +//! what you've done. +//! +//! A new character gets a name, a people they come from and a ship. Each is +//! suggested, ← and → step through more suggestions, and you can type your +//! own instead. They also get a universe to play in, which is only a seed: +//! type in someone else's to fly the same system they do. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; + +use super::flight; +use super::forge::colour; +use super::inventory::look; +use super::keys::{self, ACCENT, DIM}; +use super::sky::rgb; +use super::{Scene, Transition}; +use crate::app::{Ctx, Kept}; +use crate::dungeon::arms::Dmg; +use crate::dungeon::crawl::{Affinity, MONSTERS}; +use crate::items::{self, Gear, Item}; +use crate::names::Culture; +use crate::save::{self, Character}; +use crate::seed; +use crate::space; + +/// The most letters a name, yours or your ship's, can have. +const NAME_LENGTH: usize = 28; +/// The most digits a universe can have. Any more and it might not fit. +const UNIVERSE_DIGITS: usize = 18; +/// Universes on offer stay short enough to read aloud, like the ones the +/// clock picks. +const UNIVERSES: u64 = 1_000_000_000; + +/// A line you can type into. Until you do, it holds a suggestion, and the +/// first letter you type replaces it. +#[derive(Clone, Debug, Default)] +struct Entry { + text: String, + typed: bool, +} + +impl Entry { + fn typed(text: &str) -> Entry { + Entry { text: text.to_string(), typed: true } + } + + fn suggest(&mut self, text: String) { + self.text = text; + self.typed = false; + } + + /// Takes a key meant for the text: a letter `allowed`, backspace, or + /// ctrl+u to clear the line. Says whether it was one. + fn edit(&mut self, key: KeyEvent, allowed: fn(char) -> bool, longest: usize) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('u') if ctrl => self.text.clear(), + KeyCode::Char(c) if !ctrl && allowed(c) => { + if !self.typed { + self.text.clear(); + } + let space_wasted = c == ' ' && (self.text.is_empty() || self.text.ends_with(' ')); + if self.text.chars().count() < longest && !space_wasted { + self.text.push(c); + } + } + KeyCode::Backspace => { + self.text.pop(); + } + _ => return false, + } + self.typed = true; + true + } +} + +fn any_letter(c: char) -> bool { + !c.is_control() +} + +fn digit(c: char) -> bool { + c.is_ascii_digit() +} + +/// A name as typed, with runs of spaces closed up. +fn tidy(name: &str) -> String { + name.split_whitespace().collect::<Vec<_>>().join(" ") +} + +/// A ship's name without the "the" that goes in front of it anyway. +fn ship(name: &str) -> String { + let name = tidy(name); + if name.eq_ignore_ascii_case("the") { + return String::new(); + } + match name.get(..4) { + Some(the) if the.eq_ignore_ascii_case("the ") => name[4..].to_string(), + _ => name, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Row { + Name, + Origin, + Ship, + Universe, + Begin, +} + +const ROWS: [Row; 5] = [Row::Name, Row::Origin, Row::Ship, Row::Universe, Row::Begin]; + +/// Making a new character. +pub struct Create { + row: Row, + name: Entry, + origin: Culture, + ship: Entry, + universe: Entry, + /// What the universe makes: "the Qeth system, in Qaari space". + system: String, + /// Suggestions are `derive(suggestions, what, n)`, so ← brings back + /// what → stepped past. + suggestions: u64, + /// Which suggestion each line is on. + names: u64, + ships: u64, + universes: u64, + problem: Option<String>, +} + +impl Create { + pub fn new(ctx: &Ctx) -> Create { + let suggestions = seed::from_clock(); + // A game started for a universe (`orrery play --seed`) makes its + // first character there. Otherwise every character gets their own. + let universe = if ctx.slot.is_none() { + ctx.seed + } else { + seed::derive(suggestions, "universe", 0) % UNIVERSES + }; + let mut create = Create { + row: Row::Name, + name: Entry::default(), + origin: space::home_system(universe).culture, + ship: Entry::default(), + universe: Entry { text: universe.to_string(), typed: false }, + system: String::new(), + suggestions, + names: 0, + ships: 0, + universes: 0, + problem: None, + }; + create.describe_universe(); + create.suggest_name(); + create.suggest_ship(); + create + } + + fn rng(&self, what: &str, n: u64) -> seed::Rng { + seed::rng(seed::derive(self.suggestions, what, n)) + } + + fn suggest_name(&mut self) { + let name = self.origin.person(&mut self.rng("name", self.names)); + self.name.suggest(name); + } + + fn suggest_ship(&mut self) { + let ship = self.origin.word(&mut self.rng("ship", self.ships)); + self.ship.suggest(ship); + } + + fn describe_universe(&mut self) { + self.system = match self.universe.text.parse::<u64>() { + Ok(universe) => { + let system = space::home_system(universe); + format!("the {} system, in {} space", system.name, system.culture.adjective()) + } + Err(_) => "a universe is a number".to_string(), + }; + } + + fn move_row(&mut self, by: isize) { + let at = ROWS.iter().position(|&r| r == self.row).unwrap_or(0); + let to = (at as isize + by).clamp(0, ROWS.len() as isize - 1); + self.row = ROWS[to as usize]; + } + + /// ← and →: another suggestion, or another people. + fn step(&mut self, by: i64) { + let next = |n: u64| n.saturating_add_signed(by); + match self.row { + Row::Name => { + self.names = next(self.names); + self.suggest_name(); + } + Row::Ship => { + self.ships = next(self.ships); + self.suggest_ship(); + } + Row::Origin => { + let at = Culture::ALL.iter().position(|&c| c == self.origin).unwrap_or(0); + let count = Culture::ALL.len() as i64; + self.origin = Culture::ALL[(at as i64 + by).rem_euclid(count) as usize]; + // Names you haven't chosen follow where you're from. + if !self.name.typed { + self.suggest_name(); + } + if !self.ship.typed { + self.suggest_ship(); + } + } + Row::Universe => { + self.universes = next(self.universes); + let universe = seed::derive(self.suggestions, "universe", self.universes); + self.universe.suggest((universe % UNIVERSES).to_string()); + self.describe_universe(); + } + Row::Begin => {} + } + } + + /// Makes the character and starts them off aboard their ship, landed on + /// their universe's home world. + fn begin(&mut self, ctx: &mut Ctx) -> Transition { + let name = tidy(&self.name.text); + let ship = ship(&self.ship.text); + let universe = self.universe.text.parse::<u64>(); + let problem = if name.is_empty() { + Some((Row::Name, "You'll need a name.".to_string())) + } else if ship.is_empty() { + Some((Row::Ship, "Your ship will need a name.".to_string())) + } else if universe.is_err() { + Some((Row::Universe, "A universe is a number.".to_string())) + } else { + None + }; + if let Some((row, problem)) = problem { + self.row = row; + self.problem = Some(problem); + return Transition::None; + } + let universe = universe.unwrap(); + let character = Character { name, origin: self.origin, ship }; + let slot = ctx.saves.as_ref().map(|dir| save::new_slot(dir, &character, universe)); + if slot.as_ref().is_some_and(|path| path.exists()) { + self.row = Row::Name; + self.problem = Some(format!( + "There's already a {} in universe {universe}. Load them from the title, or \ + choose another name.", + character.name + )); + return Transition::None; + } + // Whoever you were playing, as you left them. + ctx.autosave(); + ctx.begin(universe, character); + ctx.slot = slot; + ctx.landed = true; + Transition::Replace(flight::resume(ctx)) + } +} + +impl Scene for Create { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + self.problem = None; + match key.code { + KeyCode::Esc => return Transition::Pop, + KeyCode::Up | KeyCode::BackTab => self.move_row(-1), + KeyCode::Down | KeyCode::Tab => self.move_row(1), + KeyCode::Enter if self.row == Row::Begin => return self.begin(ctx), + KeyCode::Enter => self.move_row(1), + KeyCode::Left => self.step(-1), + KeyCode::Right => self.step(1), + _ => match self.row { + Row::Name => { + self.name.edit(key, any_letter, NAME_LENGTH); + } + Row::Ship => { + self.ship.edit(key, any_letter, NAME_LENGTH); + } + Row::Universe => { + if self.universe.edit(key, digit, UNIVERSE_DIGITS) { + self.describe_universe(); + } + } + Row::Origin | Row::Begin => {} + }, + } + Transition::None + } + + fn render(&self, frame: &mut Frame, _ctx: &Ctx) { + let tint = colour(self.origin); + let chosen = |row: Row| self.row == row; + let label = |row: Row, text: &'static str| { + let marker = if chosen(row) { "› " } else { " " }; + let style = if chosen(row) { Style::new().fg(ACCENT).bold() } else { Style::new() }; + vec![Span::styled(marker, style), Span::styled(format!("{text:<10}"), style.fg(DIM))] + }; + let cursor = + |row: Row| if chosen(row) { Span::raw("▏").fg(ACCENT) } else { Span::raw("") }; + let value = |entry: &Entry| { + let span = Span::raw(entry.text.clone()); + if entry.typed { span.bold() } else { span } + }; + + let mut name = label(Row::Name, "name"); + name.extend([value(&self.name), cursor(Row::Name)]); + let mut origin = label(Row::Origin, "from"); + if chosen(Row::Origin) { + origin.extend([ + Span::raw("‹ ").fg(DIM), + Span::raw(self.origin.name()).fg(tint).bold(), + Span::raw(" ›").fg(DIM), + ]); + } else { + origin.push(Span::raw(self.origin.name()).fg(tint)); + } + let mut ship = label(Row::Ship, "ship"); + ship.extend([Span::raw("the "), value(&self.ship), cursor(Row::Ship)]); + let mut universe = label(Row::Universe, "universe"); + universe.extend([value(&self.universe), cursor(Row::Universe)]); + let begin = if chosen(Row::Begin) { + Line::from(vec![Span::raw("› ").fg(ACCENT).bold(), "[ begin ]".fg(ACCENT).bold()]) + } else { + Line::from(" [ begin ]") + }; + + let hint = match self.row { + Row::Name => { + keys::line(&[("type", "your name"), ("← →", "another"), ("enter", "next")]) + } + Row::Origin => keys::line(&[("← →", "where you're from"), ("enter", "next")]), + Row::Ship => { + keys::line(&[("type", "your ship's name"), ("← →", "another"), ("enter", "next")]) + } + Row::Universe => { + keys::line(&[("type", "a universe"), ("← →", "another"), ("enter", "next")]) + } + Row::Begin => keys::line(&[("enter", "begin")]), + }; + let indent = |text: String| Line::from(format!(" {text}")).fg(DIM); + let mut lines = vec![ + Line::from("Who are you, and what do you fly?").fg(DIM).italic(), + Line::default(), + Line::from(name), + Line::from(origin), + indent(self.origin.blurb().to_string()).italic(), + Line::from(ship), + Line::from(universe), + indent(self.system.clone()), + Line::default(), + begin, + Line::default(), + ]; + match &self.problem { + Some(problem) => lines.push(Line::from(problem.as_str()).fg(Color::LightRed)), + None => lines.push(hint), + } + lines.push(keys::line(&[("↑↓ tab", "choose a line"), ("esc", "back")])); + + let area = boxed(frame.area(), 70, lines.len() as u16 + 4); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(" A NEW CHARACTER ").fg(ACCENT).bold()) + .padding(Padding::uniform(1)); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }).block(block), area); + } +} + +/// A box up to `width` by `height`, in the middle of `area`. +fn boxed(area: Rect, width: u16, height: u16) -> Rect { + let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); + let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area); + area +} + +/// "rust-mites", "hollow crewmen", "bulkhead sentries". +fn plural(name: &str) -> String { + if let Some(stem) = name.strip_suffix("man") { + format!("{stem}men") + } else if let Some(stem) = name.strip_suffix('y') + && !stem.ends_with(['a', 'e', 'o', 'u']) + { + format!("{stem}ies") + } else { + format!("{name}s") + } +} + +/// Your character sheet: who you are, what you carry, what you've done and +/// what you've learned. You can rename your ship here. +#[derive(Default)] +pub struct Sheet { + renaming: Option<Entry>, + /// Aboard a wreck: its name, and what you carry there, which is only + /// yours once you're back through the airlock. + wreck: Option<(String, Gear)>, +} + +impl Sheet { + pub fn new() -> Sheet { + Sheet::default() + } + + /// The sheet as it stands aboard the wreck `name`, carrying `gear`. + pub fn aboard(name: &str, gear: Gear) -> Sheet { + Sheet { renaming: None, wreck: Some((name.to_string(), gear)) } + } + + /// What you carry: aboard a wreck, what you have there. + fn gear<'a>(&'a self, ctx: &'a Ctx) -> &'a Gear { + self.wreck.as_ref().map_or(&ctx.hold.gear, |(_, gear)| gear) + } + + fn who(&self, ctx: &Ctx) -> Vec<Line<'static>> { + let c = &ctx.character; + let ship = match &self.renaming { + Some(entry) => vec![ + Span::raw("the "), + Span::raw(entry.text.clone()).bold(), + Span::raw("▏").fg(ACCENT), + ], + None => vec![Span::raw(c.ship_name())], + }; + let mut calling = vec![Span::raw(format!("{}, captain of ", c.calling()))]; + calling.extend(ship); + let place = &ctx.system.places[ctx.location].name; + let whereabouts = match &self.wreck { + Some((name, _)) => format!("aboard {name}"), + None if ctx.landed => "set down".into(), + None => "flying".into(), + }; + vec![ + Line::from(c.name.clone()).fg(colour(c.origin)).bold(), + Line::from(calling), + Line::from(format!( + "day {:.1} in the {} system, {whereabouts} at {place}", + ctx.day, ctx.system.name + )) + .fg(DIM), + Line::from(format!("universe {}", ctx.seed)).fg(DIM), + ] + } + + fn hold(&self, ctx: &Ctx) -> Vec<Line<'static>> { + let h = &ctx.hold; + let g = self.gear(ctx); + // What's on you and in the lockers, like `Hold::kept`, but with + // what you carry wherever you are. + let kept = || { + let pack = g.pack.iter().enumerate().map(|(i, s)| (Kept::Pack(i), s)); + pack.chain(h.lockers.iter().enumerate().map(|(i, s)| (Kept::Lockers(i), s))) + }; + let count = |matching: fn(&Item) -> bool| -> u32 { + kept().filter(|(_, s)| matching(&s.item)).map(|(_, s)| s.count).sum() + }; + let row = |label: &str, value: String| { + Line::from(vec![Span::raw(format!("{label:<11}")).fg(DIM), Span::raw(value)]) + }; + let mut ore = format!("{}/{} t", h.ore_tons(), h.ore_capacity); + if !h.ore.is_empty() { + let each: Vec<String> = h.ore.iter().map(|(name, t)| format!("{t} {name}")).collect(); + ore = format!("{ore}: {}", each.join(", ")); + } + let mut lines = vec![ + heading("HOLD"), + row("money", format!("{} ¤", h.scrip)), + row("ore", ore), + row("fuel", format!("{}/{} jumps", h.fuel, h.fuel_capacity)), + row("medpatches", count(|i| *i == Item::Medpatch).to_string()), + row("lenses", ctx.lenses.to_string()), + row("carrying", format!("{}/{} kg", items::kg(g.carried()), items::kg(g.capacity))), + row( + "lockers", + format!("{}/{} kg", items::kg(h.locker_mass()), items::kg(items::LOCKER_CAPACITY)), + ), + row("artifacts", count(|i| matches!(i, Item::Artifact(_))).to_string()), + ]; + if h.debt > 0 { + lines.insert(3, row("owed", format!("{} ¤, for a tow", h.debt))); + } + lines.extend(kept().filter_map(|(at, s)| { + let Item::Artifact(a) = &s.item else { return None }; + let name = Span::raw(format!(" * {}{}", a.name(), at.note())); + Some(Line::from(if a.identified { name.fg(Color::LightYellow) } else { name.italic() })) + })); + lines.push(Line::default()); + lines.push(heading("READY AND WORN")); + for w in [&g.melee, &g.gun] { + let item = Item::Weapon(w.clone()); + let (glyph, colour) = look(&item); + lines.push(Line::from(vec![ + Span::raw(format!("{glyph} ")).fg(rgb(colour)), + Span::raw(w.name()), + Span::raw(format!(" {}", w.damage_text())).fg(DIM), + ])); + } + for (slot, a) in &g.worn { + let (glyph, colour) = look(&Item::Armor(a.clone())); + lines.push(Line::from(vec![ + Span::raw(format!("{glyph} ")).fg(rgb(colour)), + Span::raw(a.name()), + Span::raw(format!( + " {}, {}, {}", + slot.name(), + a.guards_text(), + a.durability_text() + )) + .fg(DIM), + ])); + } + let packed = g.pack.len(); + if packed > 0 { + let s = if packed == 1 { "" } else { "s" }; + lines.push(Line::from(format!("and {packed} thing{s} in your pack [i]")).fg(DIM)); + } + lines + } + + fn record(ctx: &Ctx) -> Vec<Line<'static>> { + let r = &ctx.record; + let row = |label: &str, n: u32| { + Line::from(vec![Span::raw(format!("{label:<16}")).fg(DIM), Span::raw(n.to_string())]) + }; + let mut lines = vec![ + heading("RECORD"), + row("wrecks boarded", r.boarded), + row("got back from", r.escaped), + row("brought down", r.downed), + row("abandoned", r.abandoned), + row("killed", r.slain_total()), + row("systems seen", ctx.visited.len() as u32), + ]; + let mut slain: Vec<(&String, &u32)> = r.slain.iter().collect(); + slain.sort_by_key(|&(name, n)| (std::cmp::Reverse(*n), name.clone())); + lines.extend(slain.into_iter().map(|(name, &n)| { + let name = if n == 1 { name.clone() } else { plural(name) }; + Line::from(format!(" {n} {name}")) + })); + lines.push(Line::default()); + lines.push(heading("WHAT YOU KNOW")); + let mut any = false; + for kind in MONSTERS.iter() { + let known: Vec<Dmg> = + Dmg::ALL.into_iter().filter(|&d| ctx.bestiary.contains(&(kind.name, d))).collect(); + if known.is_empty() { + continue; + } + any = true; + let mut spans = vec![Span::raw(format!("{} ", kind.glyph)).fg(rgb(kind.rgb))]; + spans.push(Span::raw(kind.name)); + lines.push(Line::from(spans)); + let mut takes = vec![Span::raw(" ")]; + for (i, dmg) in known.into_iter().enumerate() { + let (word, tint) = match kind.affinity(dmg) { + Affinity::Weak => ("weak to", Color::Green), + Affinity::Normal => ("takes", Color::Gray), + Affinity::Resists => ("resists", Color::Yellow), + Affinity::Immune => ("immune to", Color::Red), + }; + if i > 0 { + takes.push(Span::raw(" · ").fg(DIM)); + } + takes.push(Span::raw(format!("{word} {}", dmg.name())).fg(tint)); + } + lines.push(Line::from(takes)); + } + if !any { + lines.push( + Line::from( + "Nothing yet. Hit what lives aboard wrecks with different kinds of weapon \ + to learn what hurts them.", + ) + .fg(DIM), + ); + } + lines + } +} + +fn heading(text: &'static str) -> Line<'static> { + Line::from(text).fg(ACCENT).dim().bold() +} + +impl Scene for Sheet { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + if let Some(entry) = &mut self.renaming { + match key.code { + KeyCode::Esc => self.renaming = None, + KeyCode::Enter => { + let name = ship(&entry.text); + if !name.is_empty() { + ctx.character.ship = name; + ctx.autosave(); + } + self.renaming = None; + } + _ => { + entry.edit(key, any_letter, NAME_LENGTH); + } + } + return Transition::None; + } + match key.code { + KeyCode::Char('r') => { + self.renaming = Some(Entry::typed(&ctx.character.ship)); + Transition::None + } + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('c' | 'q') => Transition::Pop, + _ => Transition::None, + } + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let (hold, record) = (self.hold(ctx), Sheet::record(ctx)); + // Tall enough for both columns side by side, or one under the + // other when it's too narrow for that; the rest is the header, + // the footer and the border. + let wide = frame.area().width >= 74; + let body = if wide { hold.len().max(record.len()) } else { hold.len() + 1 + record.len() }; + let area = boxed(frame.area(), 100, body as u16 + 9); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(" CHARACTER ").fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let [who, _, body, footer] = Layout::vertical([ + Constraint::Length(4), + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(inner); + frame.render_widget(Paragraph::new(self.who(ctx)).wrap(Wrap { trim: false }), who); + + if wide { + let [left, _, right] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Fill(1), + ]) + .areas(body); + frame.render_widget(Paragraph::new(hold).wrap(Wrap { trim: false }), left); + frame.render_widget(Paragraph::new(record).wrap(Wrap { trim: false }), right); + } else { + let mut lines = hold; + lines.push(Line::default()); + lines.extend(record); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), body); + } + + let hints = if self.renaming.is_some() { + keys::line(&[ + ("type", "a new name"), + ("enter", "keep it"), + ("esc", "leave it as it was"), + ]) + } else { + keys::line(&[("r", "rename your ship"), ("esc", "back")]) + }; + frame.render_widget(Paragraph::new(hints), footer); + } +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use super::*; + use crate::term::KeyboardCaps; + + fn ctx() -> Ctx { + Ctx::new(7, KeyboardCaps { key_release: false }) + } + + fn press(scene: &mut dyn Scene, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn typing(scene: &mut dyn Scene, ctx: &mut Ctx, text: &str) { + for c in text.chars() { + press(scene, ctx, KeyCode::Char(c)); + } + } + + fn screen(scene: &dyn Scene, ctx: &Ctx) -> String { + let mut terminal = Terminal::new(TestBackend::new(110, 44)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + let cells: Vec<&str> = buffer.content.iter().map(|c| c.symbol()).collect(); + cells.chunks(width).map(|row| row.concat() + "\n").collect() + } + + #[test] + fn you_name_yourself_and_your_ship() { + let dir = save::scratch("create"); + let mut ctx = ctx(); + ctx.saves = Some(dir.clone()); + let mut create = Create::new(&ctx); + assert_eq!(create.universe.text, "7", "the first character plays the universe given"); + typing(&mut create, &mut ctx, "Ysolde Marr"); + press(&mut create, &mut ctx, KeyCode::Down); + press(&mut create, &mut ctx, KeyCode::Right); + press(&mut create, &mut ctx, KeyCode::Down); + typing(&mut create, &mut ctx, "the Long Patience"); + assert!(screen(&create, &ctx).contains("the the Long Patience")); + press(&mut create, &mut ctx, KeyCode::Enter); + press(&mut create, &mut ctx, KeyCode::Enter); + let Transition::Replace(scenes) = press(&mut create, &mut ctx, KeyCode::Enter) else { + panic!("begin starts the game"); + }; + assert_eq!(scenes.len(), 2, "aboard your ship, landed"); + // One step on from the people of the system the universe began in. + let began = ctx.galaxy.sites[ctx.galaxy.home].culture; + let origin = Culture::ALL[(Culture::ALL.iter().position(|&c| c == began).unwrap() + 1) % 4]; + assert_eq!( + ctx.character, + Character { name: "Ysolde Marr".into(), origin, ship: "Long Patience".into() } + ); + assert_eq!(ctx.star, ctx.galaxy.seat(origin), "you start at your own people's seat"); + assert_eq!(ctx.system.culture, origin); + assert_eq!(ctx.visited.iter().copied().collect::<Vec<_>>(), vec![ctx.star]); + assert_eq!((ctx.seed, ctx.landed, ctx.location), (7, true, ctx.system.home_place())); + assert_eq!(ctx.slot, Some(dir.join("ysolde-marr-0000000000000007.json"))); + ctx.autosave(); + assert!(ctx.slot.as_ref().unwrap().exists()); + + // The same name in the same universe would overwrite them. + let mut again = Create::new(&ctx); + again.universe.suggest("7".into()); + typing(&mut again, &mut ctx, "Ysolde Marr"); + again.row = Row::Begin; + assert!(matches!(press(&mut again, &mut ctx, KeyCode::Enter), Transition::None)); + assert!(screen(&again, &ctx).contains("There's already a Ysolde Marr")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn suggestions_step_back_and_forth_and_follow_where_youre_from() { + let mut ctx = ctx(); + ctx.slot = Some("playing.json".into()); + let mut create = Create::new(&ctx); + assert_ne!(create.universe.text, "7", "later characters get a universe of their own"); + let first = create.name.text.clone(); + press(&mut create, &mut ctx, KeyCode::Right); + assert_ne!(create.name.text, first); + press(&mut create, &mut ctx, KeyCode::Left); + assert_eq!(create.name.text, first); + + create.row = Row::Ship; + typing(&mut create, &mut ctx, "Kettle"); + create.row = Row::Origin; + press(&mut create, &mut ctx, KeyCode::Right); + assert_ne!(create.name.text, first, "a suggested name changes with your people"); + assert_eq!(create.ship.text, "Kettle", "but not one you typed"); + + create.row = Row::Universe; + typing(&mut create, &mut ctx, "12x3"); + assert_eq!(create.universe.text, "123"); + assert!(create.system.contains("system")); + } + + #[test] + fn a_character_needs_names() { + let mut ctx = ctx(); + let mut create = Create::new(&ctx); + let clear = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); + create.key(clear, &mut ctx); + create.row = Row::Begin; + assert!(matches!(press(&mut create, &mut ctx, KeyCode::Enter), Transition::None)); + assert_eq!(create.row, Row::Name); + assert!(screen(&create, &ctx).contains("You'll need a name.")); + typing(&mut create, &mut ctx, "Oru"); + create.row = Row::Ship; + create.key(clear, &mut ctx); + typing(&mut create, &mut ctx, "The "); + create.row = Row::Begin; + press(&mut create, &mut ctx, KeyCode::Enter); + assert_eq!(create.row, Row::Ship, "\"the\" alone isn't a name"); + } + + #[test] + fn the_sheet_shows_what_youve_done_and_renames_your_ship() { + let mut ctx = ctx(); + ctx.record.boarded = 4; + ctx.record.slain.insert("hollow crewman".into(), 3); + ctx.record.slain.insert("bulkhead sentry".into(), 2); + ctx.bestiary.insert(("rust-mite", Dmg::Blunt)); + let mut sheet = Sheet::new(); + let text = screen(&sheet, &ctx); + assert!(text.contains(&ctx.character.name)); + assert!(text.contains("3 hollow crewmen") && text.contains("2 bulkhead sentries")); + assert!(text.contains("rust-mite") && text.contains("blunt")); + + press(&mut sheet, &mut ctx, KeyCode::Char('r')); + sheet.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), &mut ctx); + typing(&mut sheet, &mut ctx, "Second Wind"); + assert!(matches!(press(&mut sheet, &mut ctx, KeyCode::Enter), Transition::None)); + assert_eq!(ctx.character.ship_name(), "the Second Wind"); + assert!(matches!(press(&mut sheet, &mut ctx, KeyCode::Esc), Transition::Pop)); + } + + #[test] + fn plurals() { + assert_eq!(plural("rust-mite"), "rust-mites"); + assert_eq!(plural("hollow crewman"), "hollow crewmen"); + assert_eq!(plural("bulkhead sentry"), "bulkhead sentries"); + } +} diff --git a/src/scenes/derelict.rs b/src/scenes/derelict.rs index fc18f50..5b705a4 100644 --- a/src/scenes/derelict.rs +++ b/src/scenes/derelict.rs @@ -1,5 +1,8 @@ //! Aboard a derelict: the map, your status, and the log. The rules live in -//! `dungeon::crawl`; this turns keys into actions and draws the result. +//! `dungeon::crawl`; this turns keys into actions and draws the result, +//! including the line of fire while you aim, and shots in flight. `i` opens +//! the inventory (`scenes::inventory`), with what's on the floor beside it, +//! and `c` your character sheet, with what you carry here. use ratatui::Frame; use ratatui::buffer::Buffer; @@ -9,14 +12,28 @@ use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; +use super::character::Sheet; +use super::inventory::{self, Beside, Command, Inventory, Reply, View}; +use super::keys::{self, direction}; +use super::pause::{Choice, Pause}; use super::{Scene, Transition}; use crate::app::Ctx; -use crate::dungeon::crawl::{Action, Crawl, Item, Outcome, SIGHT_RADIUS}; +use crate::dungeon::arms::{Dmg, Weapon}; +use crate::dungeon::crawl::{Action, Affinity, Crawl, Monster, Outcome, SIGHT_RADIUS, Trace}; use crate::dungeon::{Pos, Tile}; +use crate::items::{self, Gear, Item, Stack}; use crate::seed; const PANEL_WIDTH: u16 = 34; const LOG_HEIGHT: u16 = 7; +/// How fast shots cross the screen: cells for every two ticks. +const SHOT_SPEED: u64 = 3; +/// How long a shot's impact shows, in cells' worth of flight. +const IMPACT: u64 = 6; +/// Most monsters listed as in view. +const IN_VIEW: usize = 6; +/// The most steps a run takes. +const RUN_LIMIT: usize = 80; type Rgb = (u8, u8, u8); /// Cells you've seen but can't see now. @@ -24,18 +41,113 @@ const REMEMBERED: Rgb = (64, 68, 84); /// The hull's outline, seen from outside as you docked. const SILHOUETTE: Rgb = (40, 44, 60); const STAR: Rgb = (72, 72, 92); +const LINE_OF_FIRE: Color = Color::Rgb(46, 40, 20); +const RETICLE: Color = Color::Rgb(160, 128, 40); + +#[derive(Clone, Copy)] +enum Mode { + Explore, + /// Picking where to fire. + Aim(Pos), + /// Asking whether to leave by the emergency hatch. + Abandon, +} pub struct Derelict { crawl: Crawl, - /// Asking whether to leave by the emergency hatch. - abandoning: bool, + mode: Mode, + /// Open over the map, when you're looking through what you carry. + inventory: Option<Inventory>, + /// What you came aboard with, to tell what you found. + brought: Gear, /// Once the crawl is over: a title and lines for the closing summary. ending: Option<(&'static str, Vec<String>)>, + /// The tick this turn's shots set off, for drawing them in flight. + volley_at: u64, + /// The menu `esc` opens. + pause: Option<Pause>, } impl Derelict { pub fn new(seed: u64) -> Self { - Derelict { crawl: Crawl::new(seed), abandoning: false, ending: None } + Derelict { + crawl: Crawl::new(seed), + mode: Mode::Explore, + inventory: None, + brought: Gear::default(), + ending: None, + volley_at: 0, + pause: None, + } + } + + /// Back aboard the wreck a save left you in, just as you left it: what + /// you carried, what you'd seen, what's still hunting you. + pub fn resumed(ctx: &Ctx) -> Option<Derelict> { + let mut crawl = Crawl::resume(ctx.crawl.as_ref()?)?; + crawl.known.clone_from(&ctx.bestiary); + crawl.say(format!("You're back aboard {}, where you left off.", crawl.name)); + let mut brought = ctx.hold.gear.clone(); + brought.reload_all(); + Some(Derelict { + crawl, + mode: Mode::Explore, + inventory: None, + brought, + ending: None, + volley_at: 0, + pause: None, + }) + } + + /// Boards a derelict with everything you carry. Whatever's left of it + /// comes back with you if you get out; your own torch and rivet gun come + /// back whatever happens. What's in your ship's lockers stays safe + /// aboard. What you know about what lives aboard wrecks, you know here + /// too. + /// + /// The hold isn't touched until the crawl ends. The crawl itself is + /// kept in `ctx.crawl` after every move, so a game saved halfway + /// through (or quit) comes back aboard the wreck. + pub fn board(seed: u64, ctx: &mut Ctx) -> Self { + let mut derelict = Derelict::new(seed); + ctx.record.boarded += 1; + let c = &mut derelict.crawl; + c.gear = ctx.hold.gear.clone(); + c.gear.reload_all(); + c.known.clone_from(&ctx.bestiary); + derelict.brought = c.gear.clone(); + let mut brought: Vec<String> = + everything(&c.gear).iter().filter(|s| !s.item.is_kit()).map(Stack::name).collect(); + if !brought.is_empty() { + if brought.len() > 4 { + brought.truncate(3); + brought.push("more".into()); + } + let brought = listed(&brought); + c.say(format!("You've brought {brought}.")); + } + ctx.crawl = Some(derelict.crawl.saved()); + derelict + } + + /// Takes a turn (or doesn't), and keeps up with what came of it. + fn act(&mut self, action: Action, ctx: &mut Ctx) { + self.crawl.act(action); + if !self.crawl.volley.is_empty() { + self.volley_at = ctx.ticks; + } + ctx.bestiary.extend(self.crawl.known.iter().copied()); + for kind in self.crawl.slain.drain(..) { + *ctx.record.slain.entry(kind.to_string()).or_default() += 1; + } + ctx.crawl = self.crawl.outcome.is_none().then(|| self.crawl.saved()); + if let Some(outcome) = self.crawl.outcome + && self.ending.is_none() + { + self.mode = Mode::Explore; + self.finish(outcome, ctx); + } } /// Called once, when the crawl ends: banks what you carried if you got @@ -44,47 +156,190 @@ impl Derelict { let c = &self.crawl; let lines = match outcome { Outcome::Escaped => { - ctx.hold.artifacts.extend(c.artifacts.iter().cloned()); - ctx.hold.scrip += c.scrip; - let mut lines = vec![ - format!("You cast off from {}.", c.name), - String::new(), - format!( - "Recovered {} and {} ¤ of salvage.", - count(c.artifacts.len(), "artifact"), - c.scrip - ), - ]; - lines.extend(c.artifacts.iter().map(|a| format!(" * {}", a.unidentified))); + ctx.record.escaped += 1; + ctx.hold.gear = c.gear.clone(); + let found = gained(&self.brought, &c.gear); + let mut lines = vec![format!("You cast off from {}.", c.name), String::new()]; + if found.is_empty() { + lines.push("You come away with nothing new.".into()); + } else { + lines.push("You come away with:".into()); + lines.extend(found.iter().map(|s| { + let (glyph, _) = inventory::look(&s.item); + format!(" {glyph} {}", s.name()) + })); + } + let worth: u32 = found + .iter() + .map(|s| match &s.item { + Item::Salvage(v) => v.value * s.count, + _ => 0, + }) + .sum(); + if worth > 0 { + lines.push(format!("The salvage is worth about {worth} ¤ to a broker.")); + } lines.push(String::new()); lines.push(format!( - "Your hold now carries {} and {} ¤.", - count(ctx.hold.artifacts.len(), "artifact"), - ctx.hold.scrip + "You carry {} of {} kg. Stow what you don't need in the lockers [i].", + items::kg(c.gear.carried()), + items::kg(c.gear.capacity) )); lines } - Outcome::Died { killer } => vec![ - format!("The {killer} brings you down aboard {}.", c.name), - "Your crew drags you back to your ship, but".into(), - "everything you carried stays with the wreck.".into(), - ], + Outcome::Died { killer } => { + ctx.record.downed += 1; + ctx.hold.lose_carried(&c.gear); + vec![ + format!("The {killer} brings you down aboard {}.", c.name), + format!("Your crew drags you back to {}, but", ctx.character.ship_name()), + "everything you carried stays with the wreck.".into(), + ] + } }; let title = match outcome { Outcome::Escaped => " SAFE ABOARD YOUR SHIP ", Outcome::Died { .. } => " PULLED BACK FROM THE EDGE ", }; self.ending = Some((title, lines)); + // You may well be holding the key that walked you into the + // airlock: its repeats mustn't wave the summary away unread. + ctx.settle(); + } + + /// Shift and a direction: steps that way until there's something to + /// stop for: something in sight, a wound, something said or underfoot, + /// a doorway, another room, a closed door or the airlock ahead. With + /// something in sight already, it's just a step. + fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) { + for step in 0..RUN_LIMIT { + let c = &self.crawl; + // Opening a door and leaving are steps you take, not ones a + // run carries you into. + let ahead = c.map.tile(c.player.offset(dx, dy)); + if step > 0 && matches!(ahead, Tile::Airlock | Tile::Door { open: false }) { + break; + } + let (from, hp, said) = (c.player, c.hp, c.said); + let room = c.map.room_index(c.player); + self.act(Action::Move(dx, dy), ctx); + let c = &self.crawl; + let stop = c.outcome.is_some() + || c.player == from + || c.hp < hp + || c.said != said + || c.monsters.iter().any(|m| c.fov.contains(m.pos)) + || !c.here().is_empty() + || matches!(c.map.tile(c.player), Tile::Door { .. }) + // Out of a doorway, the first step is into the room. + || (step > 0 && c.map.room_index(c.player) != room); + if stop { + break; + } + } } - fn render_map(&self, buf: &mut Buffer, area: Rect) { + fn explore(&mut self, code: KeyCode) -> Option<Action> { + if let Some((dx, dy)) = direction(code) { + return Some(Action::Move(dx, dy)); + } + match code { + KeyCode::Char('.' | '5') => Some(Action::Wait), + KeyCode::Char('a') => Some(Action::UseMedpatch), + KeyCode::Char('r') => Some(Action::Reload), + KeyCode::Char('g' | ',') if self.crawl.here().len() > 1 => { + self.inventory = Some(Inventory::beside()); + None + } + KeyCode::Char('g' | ',') => Some(Action::PickUp), + KeyCode::Char('f') if self.crawl.gear.gun.loaded == 0 => Some(Action::Reload), + KeyCode::Char('f') => { + let at = self.targets().first().copied().unwrap_or(self.crawl.player); + self.mode = Mode::Aim(at); + None + } + KeyCode::Char('i') => { + self.inventory = Some(Inventory::new()); + None + } + KeyCode::Esc => { + let note = format!( + "Aboard {}. Quit, and you'll come back aboard just as you left it.", + self.crawl.name + ); + self.pause = Some(Pause::new(Some("Abandon ship"), Some(note))); + None + } + _ => None, + } + } + + fn aim(&mut self, code: KeyCode, at: Pos) -> Option<Action> { + if let Some((dx, dy)) = direction(code) { + let to = at.offset(dx, dy); + if self.crawl.map.contains(to) { + self.mode = Mode::Aim(to); + } + return None; + } + match code { + KeyCode::Tab | KeyCode::Char('+' | '=') => self.mode = Mode::Aim(self.cycle(at, 1)), + KeyCode::BackTab | KeyCode::Char('-') => self.mode = Mode::Aim(self.cycle(at, -1)), + KeyCode::Enter | KeyCode::Char('f' | 't') if at != self.crawl.player => { + self.mode = Mode::Explore; + return Some(Action::Fire(at)); + } + KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Explore, + _ => {} + } + None + } + + /// Where the things you can see are, nearest first. + fn targets(&self) -> Vec<Pos> { + let c = &self.crawl; + let mut targets: Vec<Pos> = + c.monsters.iter().map(|m| m.pos).filter(|&p| c.fov.contains(p)).collect(); + targets.sort_by_key(|&p| (distance2(c.player, p), p.y, p.x)); + targets + } + + /// The next target after (or before) the one at `at`. + fn cycle(&self, at: Pos, by: isize) -> Pos { + let targets = self.targets(); + match targets.iter().position(|&p| p == at) { + _ if targets.is_empty() => at, + Some(i) => targets[(i as isize + by).rem_euclid(targets.len() as isize) as usize], + None => targets[0], + } + } + + fn render_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) { let c = &self.crawl; let left = camera(c.player.x, c.map.width, i32::from(area.width)); let top = camera(c.player.y, c.map.height, i32::from(area.height)); + let flying = self.in_flight(ticks); + let (aim, line) = match self.mode { + Mode::Aim(at) => (Some(at), c.line_of_fire(at)), + _ => (None, Vec::new()), + }; for sy in 0..area.height { for sx in 0..area.width { let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); - let (glyph, style) = self.look(p); + let (mut glyph, mut style) = self.look(p); + if line.contains(&p) { + style = style.bg(LINE_OF_FIRE); + } + if aim == Some(p) { + style = style.bg(RETICLE); + if p != c.player && c.monster_at(p).is_none() { + (glyph, style) = ('×', style.fg(Color::Black)); + } + } + if let Some(&(_, g, colour)) = flying.iter().find(|(q, ..)| *q == p) { + glyph = g; + style = style.fg(rgb(colour, 1.0)).bold(); + } if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) { cell.set_char(glyph).set_style(style); } @@ -92,6 +347,29 @@ impl Derelict { } } + /// What of this turn's shots is on screen `ticks` in: each round's head + /// and a fading tail while it flies, then a flash where it stops. + fn in_flight(&self, ticks: u64) -> Vec<(Pos, char, Rgb)> { + let c = &self.crawl; + let flown = ticks.saturating_sub(self.volley_at) * SHOT_SPEED / 2; + let mut out = Vec::new(); + for trace in &c.volley { + let Some(flown) = flown.checked_sub(u64::from(trace.delay)) else { continue }; + let (len, colour) = (trace.cells.len() as u64, trace.dmg.rgb()); + let Some(&end) = trace.cells.last() else { continue }; + if flown < len { + out.push((trace.cells[flown as usize], shot_glyph(trace), colour)); + if flown > 0 { + out.push((trace.cells[flown as usize - 1], '·', dim(colour, 0.6))); + } + } else if flown < len + IMPACT { + out.push((end, if trace.hit { '*' } else { '∙' }, colour)); + } + } + out.retain(|(p, ..)| c.fov.contains(*p)); + out + } + /// How the cell at `p` looks from where you stand: lit by your lamp if /// you can see it, from memory if you've seen it, and otherwise only the /// hull's outline against the stars. @@ -105,10 +383,10 @@ impl Derelict { if let Some(m) = c.monster_at(p) { return (m.kind.glyph, Style::new().fg(rgb(m.kind.rgb, 1.0)).bold()); } - let dist = f64::from((p.x - c.player.x).pow(2) + (p.y - c.player.y).pow(2)).sqrt(); + let dist = f64::from(distance2(p, c.player)).sqrt(); let light = 1.0 - 0.6 * (dist / f64::from(SIGHT_RADIUS)).min(1.0); if let Some(loot) = c.loot_at(p) { - let (glyph, colour) = item_look(&loot.item); + let (glyph, colour) = inventory::look(&loot.stack.item); return (glyph, Style::new().fg(rgb(colour, light.max(0.8))).bold()); } let (glyph, colour) = tile_look(tile); @@ -116,7 +394,7 @@ impl Derelict { } if c.seen(p) { if let Some(loot) = c.loot_at(p) { - let (glyph, colour) = item_look(&loot.item); + let (glyph, colour) = inventory::look(&loot.stack.item); return (glyph, Style::new().fg(rgb(colour, 0.45))); } return (tile_look(tile).0, Style::new().fg(rgb(REMEMBERED, 1.0))); @@ -138,45 +416,213 @@ impl Derelict { .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); - let [info, help] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(inner); + let help = self.help(inner.width); + let [info, keys] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(help.len() as u16)]) + .areas(inner); - let label = |text: &'static str| Span::from(text).dark_gray(); let mut lines = vec![ Line::from(c.name.as_str()).bold().fg(Color::Yellow), Line::from(c.description.as_str()).italic().dark_gray(), Line::default(), Line::from(vec![label("health "), health_bar(c.hp, c.max_hp, 12)]), - Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.medpatches))]), + ]; + if c.bleeding > 0 { + lines.push(Line::from(vec![ + label(" "), + format!("bleeding {}", c.bleeding).red(), + ])); + } + if c.burning > 0 { + lines.push(Line::from(vec![ + label(" "), + Span::from(format!("alight {}", c.burning)).fg(rgb(Dmg::Burn.rgb(), 1.0)), + ])); + } + lines.extend([ + Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.gear.medpatches()))]), Line::from(vec![label("here "), Span::from(c.location())]), Line::from(vec![label("turn "), Span::from(c.turns.to_string())]), Line::default(), - Line::from(vec![label("salvage "), Span::from(format!("{} ¤", c.scrip))]), - Line::from(vec![label("artifacts "), Span::from(c.artifacts.len().to_string())]), - ]; - for artifact in &c.artifacts { - lines.push(Line::from(format!("* {}", artifact.unidentified)).fg(Color::Yellow)); + ]); + + let (blade, gun) = (&c.gear.melee, &c.gear.gun); + let blade_stats = format!("{} · {}%", blade.damage_text(), percent(blade.accuracy)); + lines.extend([ + Line::from(vec![label("melee "), title(blade, 20)]), + Line::from(vec![label(" "), blade_stats.dark_gray()]), + Line::from(vec![label("ranged "), title(gun, 20)]), + Line::from(vec![ + label(" "), + magazine(gun), + format!(" {}", gun.damage_text()).dark_gray(), + ]), + Line::from(vec![ + label("carrying "), + Span::from(format!( + "{} / {} kg", + items::kg(c.gear.carried()), + items::kg(c.gear.capacity) + )), + ]), + Line::default(), + ]); + + match self.mode { + Mode::Aim(at) => self.target_lines(at, &mut lines), + _ => self.in_view_lines(&mut lines), + } + + let found = gained(&self.brought, &c.gear); + if !found.is_empty() { + lines.push(Line::from("found").dark_gray()); + } + for stack in &found { + let (glyph, colour) = inventory::look(&stack.item); + lines.push(Line::from(vec![ + Span::from(format!("{glyph} ")).fg(rgb(colour, 1.0)), + Span::from(stack.name()), + ])); } frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + frame.render_widget(Paragraph::new(help), keys); + } - // Pairs of (key, what it does) on one line. - let keys = |pairs: &[(&'static str, &'static str)]| { - Line::from( - pairs - .iter() - .flat_map(|&(k, what)| [Span::from(k).bold(), Span::from(what).dark_gray()]) - .collect::<Vec<_>>(), - ) + /// The monsters you can see, nearest first, with how hurt they are. + fn in_view_lines<'a>(&'a self, lines: &mut Vec<Line<'a>>) { + let c = &self.crawl; + let mut seen: Vec<&Monster> = c.monsters.iter().filter(|m| c.fov.contains(m.pos)).collect(); + if seen.is_empty() { + return; + } + seen.sort_by_key(|m| distance2(c.player, m.pos)); + lines.push(Line::from("in view").dark_gray()); + for m in seen.iter().take(IN_VIEW) { + let mut spans = vec![ + Span::styled( + m.kind.glyph.to_string(), + Style::new().fg(rgb(m.kind.rgb, 1.0)).bold(), + ), + Span::from(format!(" {:<18}", m.kind.name)), + mini_bar(m.hp, m.kind.hp, 6), + ]; + if m.bleeding > 0 { + spans.push("•".red()); + } + if m.burning > 0 { + spans.push(Span::from("*").fg(rgb(Dmg::Burn.rgb(), 1.0))); + } + if m.stunned > 0 { + spans.push("z".yellow()); + } + lines.push(Line::from(spans)); + } + if seen.len() > IN_VIEW { + lines.push(Line::from(format!(" and {} more", seen.len() - IN_VIEW)).dark_gray()); + } + lines.push(Line::default()); + } + + /// What's under the reticle: how hurt it is, the odds, and what you + /// know of how it takes each kind of damage. + fn target_lines<'a>(&'a self, at: Pos, lines: &mut Vec<Line<'a>>) { + let c = &self.crawl; + if at == c.player { + lines.push(Line::from("Nothing in sight to shoot.").dark_gray()); + lines.push(Line::from("Aim with the move keys.").dark_gray()); + lines.push(Line::default()); + return; + } + let away = f64::from(distance2(c.player, at)).sqrt().round(); + let away = + if away == 1.0 { "1 cell away".to_string() } else { format!("{away} cells away") }; + let Some(m) = c.monster_at(at).filter(|m| c.fov.contains(m.pos)) else { + lines.push(Line::from(vec![label("target "), Span::from("nothing there")])); + let range = if c.in_range(at) { "" } else { " · out of range" }; + lines.push(Line::from(format!("{away}{range}")).dark_gray()); + lines.push(Line::default()); + return; }; - frame.render_widget( - Paragraph::new(vec![ - keys(&[("arrows hjkl yubn", " move")]), - keys(&[(".", " wait "), ("m", " medpatch")]), - keys(&[("walk into Θ", " leave with loot")]), - keys(&[("esc", " abandon ship, loot and all")]), - ]), - help, - ); + let kind = m.kind; + lines.push(Line::from(vec![ + label("target "), + Span::styled(kind.name, Style::new().fg(rgb(kind.rgb, 1.0)).bold()), + ])); + let mut state = vec![mini_bar(m.hp, kind.hp, 12)]; + if m.bleeding > 0 { + state.push(format!(" bleeding {}", m.bleeding).red()); + } + if m.burning > 0 { + state.push(Span::from(" burning").fg(rgb(Dmg::Burn.rgb(), 1.0))); + } + if m.stunned > 0 { + state.push(" stunned".yellow()); + } + lines.push(Line::from(state)); + let odds = match c.odds(at) { + _ if !c.in_range(at) => "out of range".to_string(), + Some(p) if c.gear.gun.gun.is_some_and(|g| g.pellets > 1) => { + format!("{}% a pellet", percent(p)) + } + Some(p) => format!("{}% to hit", percent(p)), + None => String::new(), + }; + lines.push(Line::from(format!("{away} · {odds}")).dark_gray()); + + let mut known: [(Affinity, Vec<&str>); 4] = [ + (Affinity::Weak, Vec::new()), + (Affinity::Resists, Vec::new()), + (Affinity::Immune, Vec::new()), + (Affinity::Normal, Vec::new()), + ]; + let mut untried = Vec::new(); + for dmg in Dmg::ALL { + match c.knows(kind, dmg) { + Some(a) => known.iter_mut().find(|(k, _)| *k == a).unwrap().1.push(dmg.name()), + None => untried.push(dmg.name()), + } + } + for (affinity, dmgs) in known { + if dmgs.is_empty() { + continue; + } + let (word, colour) = match affinity { + Affinity::Weak => ("weak to ", Color::Green), + Affinity::Resists => ("resists ", Color::Yellow), + Affinity::Immune => ("immune to ", Color::Red), + Affinity::Normal => ("no matter: ", Color::Gray), + }; + lines.push(Line::from(vec![Span::from(word).fg(colour), Span::from(dmgs.join(", "))])); + } + if !untried.is_empty() { + lines.push(Line::from(format!("untried: {}", untried.join(", "))).dark_gray()); + } + lines.push(Line::default()); + } + + fn help(&self, width: u16) -> Vec<Line<'static>> { + let hints: &[(&str, &str)] = match self.mode { + Mode::Aim(_) => &[ + ("move keys", "aim"), + ("tab", "next target"), + ("f enter", "fire"), + ("esc", "lower it"), + ], + _ => &[ + ("arrows hjkl yubn", "move, attack"), + ("shift", "run"), + ("f", "fire"), + ("r", "reload"), + (".", "wait"), + ("a", "medpatch"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("into Θ", "leave with loot"), + ("esc", "menu: quit or abandon ship"), + ], + }; + keys::packed(hints, width) } fn render_log(&self, frame: &mut Frame, area: Rect) { @@ -201,75 +647,214 @@ impl Derelict { } } +/// Everything on you, as stacks: worn, ready and packed. +fn everything(gear: &Gear) -> Vec<Stack> { + let worn = gear.worn.values().map(|a| Stack::one(Item::Armor(a.clone()))); + let ready = [&gear.melee, &gear.gun].into_iter().map(|w| Stack::one(Item::Weapon(w.clone()))); + worn.chain(ready).chain(gear.pack.iter().cloned()).collect() +} + +/// What you have now that you didn't come aboard with. +fn gained(before: &Gear, after: &Gear) -> Vec<Stack> { + // A gun you've fired is still the gun you brought. + let unloaded = |mut s: Stack| { + if let Item::Weapon(w) = &mut s.item { + w.loaded = 0; + } + s + }; + let mut had: Vec<Stack> = everything(before).into_iter().map(unloaded).collect(); + let mut found = Vec::new(); + for stack in everything(after).into_iter().map(unloaded) { + let mut count = stack.count; + if let Some(old) = had.iter_mut().find(|s| s.item == stack.item) { + let same = old.count.min(count); + old.count -= same; + count -= same; + } + if count > 0 { + items::stow(&mut found, Stack { count, ..stack }); + } + } + found +} + impl Scene for Derelict { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { if self.ending.is_some() { return Transition::Pop; } - if self.abandoning { - self.abandoning = false; - return match key.code { - KeyCode::Char('y' | 'Y') => Transition::Pop, - _ => Transition::None, + if let Some(pause) = &mut self.pause { + return match pause.key(key) { + None => Transition::None, + Some(choice) => { + self.pause = None; + match choice { + Choice::Quit => Transition::Title, + Choice::Here => { + self.mode = Mode::Abandon; + Transition::None + } + Choice::Resume => Transition::None, + } + } }; } - // Vi keys, arrows, and the number pad with or without num lock. - let action = match key.code { - KeyCode::Left | KeyCode::Char('h' | '4') => Action::Move(-1, 0), - KeyCode::Right | KeyCode::Char('l' | '6') => Action::Move(1, 0), - KeyCode::Up | KeyCode::Char('k' | '8') => Action::Move(0, -1), - KeyCode::Down | KeyCode::Char('j' | '2') => Action::Move(0, 1), - KeyCode::Home | KeyCode::Char('y' | '7') => Action::Move(-1, -1), - KeyCode::PageUp | KeyCode::Char('u' | '9') => Action::Move(1, -1), - KeyCode::End | KeyCode::Char('b' | '1') => Action::Move(-1, 1), - KeyCode::PageDown | KeyCode::Char('n' | '3') => Action::Move(1, 1), - KeyCode::Char('.' | '5') => Action::Wait, - KeyCode::Char('m') => Action::UseMedpatch, - KeyCode::Esc => { - self.abandoning = true; - return Transition::None; + if let Some(inv) = &mut self.inventory { + let here: Vec<Stack> = + self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); + let view = View { + gear: &self.crawl.gear, + beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), + known: &self.crawl.known, + hurt: true, + shop: None, + sort: ctx.inventory_sort, + }; + let action = match inv.key(key, &view) { + Reply::Stay => return Transition::None, + Reply::Close => { + self.inventory = None; + return Transition::None; + } + Reply::Sort(sort) => { + ctx.inventory_sort = sort; + return Transition::None; + } + Reply::Do(command) => match command { + Command::Ready(i) => Action::Ready(i), + Command::Wear(i) => Action::Wear(i), + Command::TakeOff(slot) => Action::TakeOff(slot), + Command::Eat(i) => Action::Eat(i), + Command::UseMedpatch => Action::UseMedpatch, + Command::Drop(spot, n) | Command::Stow(spot, n) => Action::Drop(spot, n), + Command::Fetch(i, n) => Action::Take(i, n), + // There's no one to trade with aboard a wreck. + Command::Buy(_) + | Command::Sell(_) + | Command::SellAboard(_) + | Command::Mend(_) + | Command::MendAboard(_) + | Command::MendAll => { + return Transition::None; + } + }, + }; + let turns = self.crawl.turns; + self.act(action, ctx); + // Anything that took a turn closes it, so you see what happened. + match &mut self.inventory { + Some(_) if self.crawl.turns != turns || self.ending.is_some() => { + self.inventory = None; + } + Some(inv) => inv.note = self.crawl.log.last().cloned(), + None => {} + } + return Transition::None; + } + let action = match self.mode { + Mode::Abandon => { + self.mode = Mode::Explore; + return match key.code { + KeyCode::Char('y' | 'Y') => { + ctx.record.abandoned += 1; + ctx.hold.lose_carried(&self.crawl.gear); + ctx.crawl = None; + Transition::Pop + } + _ => Transition::None, + }; } - _ => return Transition::None, + Mode::Explore => match keys::movement(key) { + Some((dx, dy, true)) => { + self.run(dx, dy, ctx); + return Transition::None; + } + _ if matches!(key.code, KeyCode::Char('c' | 'C')) => { + let gear = self.crawl.gear.clone(); + return Transition::Push(Box::new(Sheet::aboard(&self.crawl.name, gear))); + } + _ => self.explore(key.code), + }, + Mode::Aim(at) => self.aim(key.code, at), }; - self.crawl.act(action); - if let Some(outcome) = self.crawl.outcome { - self.finish(outcome, ctx); + if let Some(action) = action { + self.act(action, ctx); } Transition::None } - fn render(&self, frame: &mut Frame, _ctx: &Ctx) { + fn render(&self, frame: &mut Frame, ctx: &Ctx) { let [main, panel] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) .areas(frame.area()); let [map, log] = Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); - self.render_map(frame.buffer_mut(), map); + self.render_map(frame.buffer_mut(), map, ctx.ticks); self.render_log(frame, log); self.render_panel(frame, panel); - if self.abandoning { - popup( + if let Some((title, lines)) = &self.ending { + let mut lines: Vec<Line> = lines.iter().map(|l| Line::from(l.as_str())).collect(); + lines.extend([Line::default(), keys::line(&[("any key", "carry on")])]); + popup(frame, title, lines); + return; + } + match self.mode { + Mode::Abandon => popup( frame, " ABANDON SHIP? ", vec![ - format!("Leave {} by the emergency hatch?", self.crawl.name), - "Whatever you're carrying stays behind.".into(), - String::new(), - "[y] abandon [any other key] keep exploring".into(), + Line::from(format!("Leave {} by the emergency hatch?", self.crawl.name)), + Line::from("Whatever you're carrying stays behind."), + Line::default(), + keys::line(&[("y", "abandon"), ("any other key", "keep exploring")]), ], - ); - } else if let Some((title, lines)) = &self.ending { - let mut lines = lines.clone(); - lines.extend([String::new(), "[any key]".into()]); - popup(frame, title, lines); + ), + Mode::Explore | Mode::Aim(_) => {} + } + if let Some(inv) = &self.inventory { + let here: Vec<Stack> = + self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); + let view = View { + gear: &self.crawl.gear, + beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), + known: &self.crawl.known, + hurt: true, + shop: None, + sort: ctx.inventory_sort, + }; + inv.render(frame, frame.area(), &view); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); } } } +/// A round in flight, pointing the way it's going. Cells are about twice as +/// tall as they're wide, so the slope on screen is double the slope in cells. +fn shot_glyph(trace: &Trace) -> char { + match trace.dmg { + Dmg::Burn => return '•', + Dmg::Shock => return '~', + Dmg::Hymn => return '♪', + _ => {} + } + let (Some(first), Some(last)) = (trace.cells.first(), trace.cells.last()) else { return '*' }; + let (dx, dy) = (f64::from(last.x - first.x), f64::from(last.y - first.y) * 2.0); + let angle = dy.atan2(dx).to_degrees().rem_euclid(180.0); + match angle { + a if !(22.5..157.5).contains(&a) => '-', + a if a < 67.5 => '\\', + a if a < 112.5 => '|', + _ => '/', + } +} + /// The left or top edge of the view: keeps `focus` centred, but doesn't /// scroll past the map's edges, and centres a map smaller than the view. -fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 { +pub(crate) fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 { if map_size <= view_size { -(view_size - map_size) / 2 } else { @@ -290,12 +875,34 @@ pub(crate) fn tile_look(tile: Tile) -> (char, Rgb) { } } -pub(crate) fn item_look(item: &Item) -> (char, Rgb) { - match item { - Item::Artifact(_) => ('*', (255, 214, 92)), - Item::Salvage { .. } => ('%', (150, 200, 222)), - Item::Medpatch => ('!', (240, 96, 96)), +/// A weapon's name in the colour of its damage, cut to `width`. Relics +/// stand out. +fn title(w: &Weapon, width: usize) -> Span<'static> { + let name: String = w.name().chars().take(width).collect(); + let style = Style::new().fg(rgb(w.dmg.rgb(), 1.0)); + Span::styled(name, if w.named { style.bold() } else { style }) +} + +/// Rounds left, as pips. +fn magazine(gun: &Weapon) -> Span<'static> { + let Some(g) = gun.gun else { return Span::from("") }; + if gun.loaded == 0 { + return "empty (r)".red(); } + let pips = "▮".repeat(gun.loaded as usize) + &"▯".repeat((g.magazine - gun.loaded) as usize); + Span::from(pips).white() +} + +fn label(text: &'static str) -> Span<'static> { + Span::from(text).dark_gray() +} + +fn percent(p: f64) -> i32 { + (p * 100.0).round() as i32 +} + +fn distance2(a: Pos, b: Pos) -> i32 { + (a.x - b.x).pow(2) + (a.y - b.y).pow(2) } fn rgb((r, g, b): Rgb, light: f64) -> Color { @@ -303,6 +910,11 @@ fn rgb((r, g, b): Rgb, light: f64) -> Color { Color::Rgb(scale(r), scale(g), scale(b)) } +fn dim((r, g, b): Rgb, k: f64) -> Rgb { + let scale = |c: u8| (f64::from(c) * k).round() as u8; + (scale(r), scale(g), scale(b)) +} + /// A sprinkling of stars that stays put as the view scrolls. fn is_star(p: Pos) -> bool { let cell = (u64::from(p.x as u32) << 32) | u64::from(p.y as u32); @@ -320,14 +932,32 @@ fn health_bar(hp: i32, max: i32, width: i32) -> Span<'static> { Span::styled(format!("{bar} {hp}/{max}"), Style::new().fg(colour)) } -fn count(n: usize, noun: &str) -> String { - if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") } +/// A monster's health, without the numbers: you can see how hurt it is, +/// not count its hit points. +fn mini_bar(hp: i32, max: i32, width: i32) -> Span<'static> { + let hp = hp.clamp(0, max); + let filled = ((hp * width + max - 1) / max).clamp(0, width); + let colour = match hp * 4 / max { + 3.. => Color::Green, + 2 => Color::Yellow, + _ => Color::Red, + }; + let bar = "█".repeat(filled as usize) + &"░".repeat((width - filled) as usize); + Span::styled(bar, Style::new().fg(colour)) } -fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) { +fn listed(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +fn popup(frame: &mut Frame, title: &str, lines: Vec<Line>) { let area = frame.area(); - let widest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16; - let width = (widest + 4).min(area.width); + let widest = lines.iter().map(Line::width).max().unwrap_or(0) as u16; + let width = (widest + 4).max(title.chars().count() as u16 + 4).min(area.width); let height = (lines.len() as u16 + 2).min(area.height); let [popup] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); let [popup] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup); @@ -335,7 +965,6 @@ fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) { .border_style(Style::new().fg(Color::Yellow)) .title(Line::from(title.to_string()).bold()) .padding(Padding::horizontal(1)); - let lines: Vec<Line> = lines.into_iter().map(Line::from).collect(); frame.render_widget(Clear, popup); frame.render_widget(Paragraph::new(lines).block(block), popup); } @@ -347,7 +976,9 @@ mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; + use crate::dungeon::arms::{self, Source}; use crate::term::KeyboardCaps; + use ratatui::crossterm::event::KeyCode as Key; fn ctx() -> Ctx { Ctx::new(1, KeyboardCaps { key_release: false }) @@ -379,27 +1010,100 @@ mod tests { }) } + fn shifted(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::SHIFT), ctx) + } + + #[test] + fn running_goes_until_something_happens_but_not_out_the_airlock() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + let start = scene.crawl.player; + let toward = key_toward_airlock(&scene); + let KeyCode::Char(c) = toward else { unreachable!() }; + let away = keys::direction(toward).map(|(dx, dy)| (-dx, -dy)).unwrap(); + let away = [ + Key::Left, + Key::Right, + Key::Up, + Key::Down, + Key::Home, + Key::PageUp, + Key::End, + Key::PageDown, + ] + .into_iter() + .find(|&k| keys::direction(k) == Some(away)) + .unwrap(); + shifted(&mut scene, away, &mut ctx); + let ran = scene.crawl.player.steps_to(start); + assert!(ran >= 1, "it gets going"); + assert_eq!(scene.crawl.turns, ran as u32, "a turn a step, straight on"); + // And back: it stops beside the airlock rather than go through. + for _ in 0..ran { + shifted(&mut scene, KeyCode::Char(c.to_ascii_uppercase()), &mut ctx); + if scene.crawl.player == start { + break; + } + } + assert_eq!(scene.crawl.player, start); + assert!(scene.crawl.outcome.is_none(), "still aboard"); + assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); + assert!(scene.crawl.outcome.is_some(), "a step of your own takes you out"); + } + + #[test] + fn a_medpatch_is_a_and_the_sheet_is_c() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(medpatches(1)); + scene.crawl.hp -= 5; + press(&mut scene, KeyCode::Char('a'), &mut ctx); + assert_eq!(scene.crawl.gear.medpatches(), 0); + assert!(matches!(press(&mut scene, KeyCode::Char('c'), &mut ctx), Transition::Push(_))); + } + + fn medpatches(n: u32) -> Stack { + Stack { item: Item::Medpatch, count: n } + } + + fn salvage() -> Item { + Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 12, mass: 2000 }) + } + #[test] fn escaping_banks_what_you_carry() { let mut ctx = ctx(); - let mut scene = Derelict::new(3); - scene.crawl.scrip = 55; + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack { item: salvage(), count: 2 }); let toward = key_toward_airlock(&scene); assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); - assert_eq!(ctx.hold.scrip, 55); - assert!(screen(&scene, &ctx).contains("SAFE ABOARD YOUR SHIP")); + assert_eq!(ctx.hold.gear.pack.iter().filter(|s| s.item == salvage()).count(), 1); + let shown = screen(&scene, &ctx); + assert!(shown.contains("SAFE ABOARD YOUR SHIP")); + assert!(shown.contains("2 coils of wire") && shown.contains("24 ¤"), "{shown}"); assert!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop)); } + /// Esc, then the menu's second choice, then yes. + fn abandon(scene: &mut Derelict, ctx: &mut Ctx) { + press(scene, KeyCode::Esc, ctx); + press(scene, KeyCode::Char('2'), ctx); + press(scene, KeyCode::Char('y'), ctx); + } + #[test] fn abandoning_leaves_it_behind() { let mut ctx = ctx(); - let mut scene = Derelict::new(3); - scene.crawl.scrip = 55; + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack::one(salvage())); press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(screen(&scene, &ctx).contains("PAUSED")); + press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(screen(&scene, &ctx).contains("ABANDON SHIP?")); assert!(matches!(press(&mut scene, KeyCode::Char('y'), &mut ctx), Transition::Pop)); - assert_eq!(ctx.hold.scrip, 0); + assert!(ctx.hold.gear.pack.iter().all(|s| s.item.is_kit())); + assert!(ctx.crawl.is_none(), "nothing to come back to"); } #[test] @@ -407,7 +1111,196 @@ mod tests { let mut ctx = ctx(); let mut scene = Derelict::new(3); press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(press(&mut scene, KeyCode::Char('q'), &mut ctx), Transition::None)); + assert!(!screen(&scene, &ctx).contains("PAUSED")); + press(&mut scene, KeyCode::Esc, &mut ctx); + press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None)); assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?")); } + + #[test] + fn saving_and_quitting_brings_you_back_where_you_were() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack::one(salvage())); + let key = key_toward_airlock(&scene); + // A step away from the airlock and back is a turn or two in. + let away = match key { + KeyCode::Char('h') => KeyCode::Char('l'), + KeyCode::Char('l') => KeyCode::Char('h'), + KeyCode::Char('j') => KeyCode::Char('k'), + _ => KeyCode::Char('j'), + }; + press(&mut scene, away, &mut ctx); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(press(&mut scene, KeyCode::Char('3'), &mut ctx), Transition::Title)); + let saved = ctx.crawl.clone().expect("the wreck is kept"); + assert_eq!(saved, scene.crawl.saved()); + + let back = Derelict::resumed(&ctx).unwrap(); + let (was, is) = (&scene.crawl, &back.crawl); + assert_eq!((is.player, is.hp, is.turns), (was.player, was.hp, was.turns)); + assert_eq!(is.gear, was.gear); + assert_eq!(is.loot, was.loot); + assert_eq!(is.monsters.len(), was.monsters.len()); + assert!(is.log.last().unwrap().contains("where you left off")); + assert_eq!(back.brought, ctx.hold.gear, "what's found is what you didn't bring"); + assert_eq!(ctx.record.boarded, 1, "coming back isn't boarding again"); + let seen = |c: &Crawl| c.map.positions().filter(|&p| c.seen(p)).count(); + assert_eq!(seen(is), seen(was), "what you'd seen you still have"); + assert!(was.map.positions().all(|p| is.seen(p) == was.seen(p))); + } + + #[test] + fn medpatches_go_aboard_and_come_back_if_you_do() { + let mut ctx = ctx(); + ctx.hold.gear.add(medpatches(3)); + ctx.hold.lockers.push(medpatches(4)); + let mut scene = Derelict::board(3, &mut ctx); + assert_eq!((scene.crawl.gear.medpatches(), ctx.hold.medpatches()), (3, 7)); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + assert_eq!(scene.crawl.outcome, Some(Outcome::Escaped)); + assert_eq!(ctx.hold.gear.medpatches(), 3); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + assert_eq!(ctx.hold.gear.medpatches(), 0, "abandon ship and they stay behind"); + assert_eq!(ctx.hold.medpatches(), 4, "but not what's in the lockers"); + } + + #[test] + fn weapons_go_aboard_and_come_back_if_you_do() { + let mut ctx = ctx(); + let mut cleaver = arms::find(&mut seed::rng(2), Source::Crew, false); + cleaver.loaded = 0; + ctx.hold.gear.add(Stack::one(Item::Weapon(cleaver.clone()))); + let found = |g: &Gear| g.weapons().filter(|w| !w.kit).count(); + let mut scene = Derelict::board(3, &mut ctx); + assert_eq!((found(&scene.crawl.gear), found(&ctx.hold.gear)), (1, 1)); + assert!(scene.crawl.gear.weapons().all(|w| w.loaded == w.gun.map_or(0, |g| g.magazine))); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + assert_eq!(found(&ctx.hold.gear), 1); + assert!(screen(&scene, &ctx).contains("nothing new"), "what you brought isn't found"); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + assert_eq!(found(&ctx.hold.gear), 0, "abandon ship and it stays behind"); + assert_eq!(ctx.hold.gear.weapons().count(), 2, "but not your own torch and rivet gun"); + } + + #[test] + fn aiming_and_the_inventory_open_and_close() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + press(&mut scene, KeyCode::Char('f'), &mut ctx); + assert!(matches!(scene.mode, Mode::Aim(_))); + assert!(screen(&scene, &ctx).contains("next target")); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(scene.mode, Mode::Explore)); + assert_eq!(scene.crawl.turns, 0, "lowering your gun takes no time"); + + press(&mut scene, KeyCode::Char('i'), &mut ctx); + let shown = screen(&scene, &ctx); + assert!(shown.contains("YOU ·") && shown.contains("cutting torch"), "{shown}"); + press(&mut scene, KeyCode::Char('j'), &mut ctx); + assert_eq!(scene.crawl.turns, 0, "looking takes no time"); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(scene.inventory.is_none()); + } + + #[test] + fn using_things_from_the_inventory_takes_a_turn_and_closes_it() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + scene.crawl.gear.add(Stack::one(salvage())); + scene.crawl.gear.add(medpatches(1)); + scene.crawl.hp -= 5; + press(&mut scene, KeyCode::Char('i'), &mut ctx); + find(&mut scene, "coil", &mut ctx); + press(&mut scene, KeyCode::Char('d'), &mut ctx); + assert_eq!(scene.crawl.turns, 0, "dropping is free"); + assert!(scene.inventory.is_some(), "and leaves it open"); + assert_eq!(scene.crawl.here().len(), 1); + find(&mut scene, "medpatch", &mut ctx); + press(&mut scene, KeyCode::Char('a'), &mut ctx); + assert_eq!(scene.crawl.turns, 1, "a medpatch takes a turn"); + assert!(scene.inventory.is_none(), "and closes it"); + } + + /// Picks out the first thing in the open inventory called `what`. + fn find(scene: &mut Derelict, what: &str, ctx: &mut Ctx) { + press(scene, KeyCode::Char('/'), ctx); + press(scene, KeyCode::Esc, ctx); + press(scene, KeyCode::Char('/'), ctx); + for c in what.chars() { + press(scene, KeyCode::Char(c), ctx); + } + press(scene, KeyCode::Enter, ctx); + } + + #[test] + fn firing_takes_a_turn_and_spends_a_round() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + press(&mut scene, KeyCode::Char('f'), &mut ctx); + scene.mode = Mode::Aim(scene.crawl.player.offset(2, 0)); + ctx.ticks = 100; + press(&mut scene, KeyCode::Char('f'), &mut ctx); + assert_eq!(scene.crawl.turns, 1); + assert_eq!(scene.crawl.gear.gun.loaded, scene.crawl.gear.gun.gun.unwrap().magazine - 1); + assert!(!scene.crawl.volley.is_empty()); + assert_eq!(scene.volley_at, 100); + assert!(scene.in_flight(100_000).is_empty(), "long since landed"); + } + + #[test] + fn your_record_counts_wrecks_kills_and_how_you_left_them() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + let kind = scene.crawl.monsters[0].kind.name; + scene.crawl.monsters[0].hp = 0; + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert_eq!(ctx.record.slain.get(kind), Some(&1)); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + + ctx.hold.gear.add(medpatches(2)); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.bleeding = 100; + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert!(matches!(scene.crawl.outcome, Some(Outcome::Died { .. }))); + assert!(screen(&scene, &ctx).contains(&ctx.character.ship_name())); + assert_eq!(ctx.hold.gear.medpatches(), 0, "brought down, and they stay behind"); + + let r = &ctx.record; + assert_eq!((r.boarded, r.escaped, r.abandoned, r.downed), (3, 1, 1, 1)); + } + + #[test] + fn until_you_leave_the_wreck_your_hold_is_as_you_left_it() { + let mut ctx = ctx(); + ctx.hold.gear.add(medpatches(2)); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear = Gear::default(); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert_eq!(ctx.hold.gear.medpatches(), 2, "so a game saved now still has them"); + } + + #[test] + fn what_you_learn_you_keep() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.known.insert(("rust-mite", Dmg::Blunt)); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert!(ctx.bestiary.contains(&("rust-mite", Dmg::Blunt))); + let scene = Derelict::board(4, &mut ctx); + assert!(scene.crawl.known.contains(&("rust-mite", Dmg::Blunt))); + } } diff --git a/src/scenes/flight.rs b/src/scenes/flight.rs new file mode 100644 index 0000000..04c56d0 --- /dev/null +++ b/src/scenes/flight.rs @@ -0,0 +1,1163 @@ +//! Flying around one place: the local map, the ship, the laser, and a side +//! panel with a radar and your status. The rules live in `space::flight`; +//! this turns keys into `Controls` and draws the result. +//! +//! On terminals that report key releases, you hold keys to thrust, turn and +//! fire. Elsewhere there's no telling when a key is let go, so thrust is a +//! throttle you step up and down, each turn press swings you round 22.5°, +//! and each laser press fires for a moment. + +use std::f64::consts::{FRAC_PI_2, PI}; + +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; + +use super::aboard::Aboard; +use super::character::Sheet; +use super::derelict::Derelict; +use super::galaxy_map::GalaxyMap; +use super::keys; +use super::pause::{Choice, Pause}; +use super::port::{FUEL, Port}; +use super::sky::{self, Surface, View, mix}; +use super::system_map::SystemMap; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold, TICKS_PER_SECOND}; +use crate::seed; +use crate::space::Vec2; +use crate::space::flight::{ + Controls, Event, FLOTSAM_LIFE, Flight, LANDING_SPEED, SPARK_LIFE, THRUST, TURN_RATE, +}; +use crate::space::locale::{self, BACKDROP_PARALLAX, Size}; +use crate::space::system::Landing; +use crate::trade; + +const DT: f64 = 1.0 / TICKS_PER_SECOND as f64; +const PANEL_WIDTH: u16 = 30; +/// Below this width the panel gives way to a one-line status bar. +const PANEL_MIN_SCREEN: u16 = 76; +/// Throttle steps, for terminals without held keys. +const THROTTLE_STEPS: u8 = 4; +/// How far one turn press swings you, without held keys. +const TURN_STEP: f64 = PI / 8.0; +/// How long one laser press fires for, without held keys. +const FIRE_TICKS: u64 = TICKS_PER_SECOND as u64 * 6 / 10; +/// Seconds a message stays up. +const MESSAGE_SECONDS: u64 = 6; + +const HULL: Color = Color::Rgb(112, 118, 140); +const ROCK: (u8, u8, u8) = (150, 138, 124); +const BEAM: Color = Color::Rgb(255, 110, 220); +const DIM: Color = Color::Rgb(90, 94, 112); +const ACCENT: Color = Color::Rgb(240, 200, 110); + +/// Why you're out of the pilot's seat, walking about the ship: that scene +/// is on top of this one until you sit back down. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Walking { + /// Landed or docked. + Landed, + /// Got up while flying slowly, and the ship holds station. + Adrift, +} + +/// What tab points you at. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Target { + /// Whichever rock with ore in it is nearest right now. + NearestOre, + /// The planet, moon or station in the middle. + Middle, + /// A wreck, by index into `locale.wrecks`. + Wreck(usize), +} + +pub struct FlightScene { + flight: Flight, + /// Which system, place and day `flight` was built for. When the map + /// moves you on, these fall out of date and the scene rebuilds. + star: usize, + place: usize, + day: f64, + throttle: u8, + /// Turning still to do, in radians, from turn presses. + pending_turn: f64, + /// Turning to face backwards and thrusting until stopped. + braking: bool, + fire_until: u64, + target: Option<Target>, + messages: Vec<(String, u64)>, + walking: Option<Walking>, + /// The menu `esc` opens. Everything holds still while it's open. + pause: Option<Pause>, +} + +impl FlightScene { + pub fn new(ctx: &Ctx) -> FlightScene { + let mut scene = FlightScene { + flight: arrive(ctx), + star: ctx.star, + place: ctx.location, + day: ctx.day, + throttle: 0, + pending_turn: 0.0, + braking: false, + fire_until: 0, + target: None, + messages: Vec::new(), + walking: None, + pause: None, + }; + let l = &scene.flight.locale; + let text = format!("You're {}.", l.what); + scene.say(text, ctx); + scene + } + + fn say(&mut self, text: String, ctx: &Ctx) { + self.messages.retain(|(t, _)| *t != text); + self.messages.push((text, ctx.ticks)); + if self.messages.len() > 6 { + self.messages.remove(0); + } + } + + /// If the map has moved you on (or let time pass), arrives at the new + /// place. + fn catch_up(&mut self, ctx: &mut Ctx) { + if self.star == ctx.star && self.place == ctx.location && self.day == ctx.day { + return; + } + let days = ctx.day - self.day; + let moved = self.place != ctx.location; + let jumped = self.star != ctx.star; + self.flight = arrive(ctx); + self.star = ctx.star; + self.place = ctx.location; + self.day = ctx.day; + self.throttle = 0; + self.pending_turn = 0.0; + self.braking = false; + self.target = None; + self.messages.clear(); + let l = &self.flight.locale; + let note = ctx.arrival_note.take(); + let text = if let Some(note) = note { + note.replace("{where}", &l.what) + } else if jumped { + format!( + "{} later you arrive in the {} system, {}.", + days_text(days), + ctx.system.name, + l.what + ) + } else if moved { + format!("{} later you arrive {}.", days_text(days), l.what) + } else if (days - 1.0).abs() < 0.05 { + "A day passes, and the rocks drift on.".to_string() + } else { + format!("{} pass, and the rocks drift on.", days_text(days)) + }; + self.say(text, ctx); + if jumped && ctx.stranded() { + let text = + "Your tank's dry, and no one here sells fuel. [g] the star map, to call a tow."; + self.say(text.to_string(), ctx); + } + } + + /// Back in the pilot's seat, if you'd got up. + fn sit_down(&mut self, ctx: &mut Ctx) { + let Some(walking) = self.walking.take() else { return }; + ctx.landed = false; + let name = &ctx.system.places[self.place].name; + let text = match walking { + Walking::Landed if matches!(ctx.system.landing(self.place), Landing::Market(_)) => { + format!("You undock from {name}.") + } + Walking::Landed => format!("You lift off from {name}."), + Walking::Adrift => "You sit back down and take the controls.".to_string(), + }; + self.say(text, ctx); + } + + fn held(ctx: &Ctx, codes: &[KeyCode]) -> bool { + codes.iter().any(|&c| ctx.is_held(c)) + } + + fn controls(&mut self, ctx: &Ctx) -> Controls { + let mut c = Controls::default(); + if ctx.keyboard.key_release { + let left = Self::held(ctx, &[KeyCode::Left, KeyCode::Char('a')]); + let right = Self::held(ctx, &[KeyCode::Right, KeyCode::Char('d')]); + let thrust = Self::held(ctx, &[KeyCode::Up, KeyCode::Char('w')]); + if left || right || thrust { + self.braking = false; + } + if Self::held(ctx, &[KeyCode::Down, KeyCode::Char('s')]) { + self.braking = true; + } + c.turn = f64::from(u8::from(right)) - f64::from(u8::from(left)); + c.thrust = f64::from(u8::from(thrust)); + c.firing = Self::held(ctx, &[KeyCode::Char(' '), KeyCode::Char('f')]); + } else { + let step = TURN_RATE * DT; + c.turn = (self.pending_turn / step).clamp(-1.0, 1.0); + self.pending_turn -= c.turn * step; + c.thrust = f64::from(self.throttle) / f64::from(THROTTLE_STEPS); + c.firing = ctx.ticks < self.fire_until; + } + if self.braking { + let speed = self.flight.speed(); + if speed < 0.3 { + // Close enough: hold station. + self.flight.ship.vel = Vec2::ZERO; + self.braking = false; + self.throttle = 0; + } else { + c.retro = true; + // Full thrust against the drift, easing off on the last tick + // so as not to overshoot. + let enough = speed / (THRUST * DT); + c.thrust = if self.flight.facing_retro() { enough.min(1.0) } else { 0.0 }; + } + } + c + } + + fn key_fallback(&mut self, code: KeyCode, ctx: &Ctx) -> bool { + match code { + KeyCode::Up | KeyCode::Char('w') => { + self.throttle = (self.throttle + 1).min(THROTTLE_STEPS); + self.braking = false; + } + KeyCode::Down | KeyCode::Char('s') => { + if self.throttle > 0 { + self.throttle -= 1; + } else { + self.braking = true; + } + } + KeyCode::Left | KeyCode::Char('a') => self.turn_by(-TURN_STEP), + KeyCode::Right | KeyCode::Char('d') => self.turn_by(TURN_STEP), + KeyCode::Char(' ' | 'f') => self.fire_until = ctx.ticks + FIRE_TICKS, + KeyCode::Char('x') => self.throttle = 0, + _ => return false, + } + true + } + + fn turn_by(&mut self, angle: f64) { + self.braking = false; + self.pending_turn = (self.pending_turn + angle).clamp(-FRAC_PI_2, FRAC_PI_2); + } + + fn land(&mut self, ctx: &mut Ctx) -> Transition { + let f = &self.flight; + let l = &f.locale; + let fast = f.speed() > LANDING_SPEED; + let refusal = if let Some(body) = &l.body { + if !f.over_body() { + format!("Fly over {} to land.", body.name) + } else if body.kind.giant { + format!( + "{} has no surface to land on: it's {} all the way down.", + body.name, body.kind.name + ) + } else if fast { + format!("Too fast to land. Slow below {LANDING_SPEED:.0}.") + } else { + return self.touch_down(ctx); + } + } else if let Some(dock) = &l.dock { + if !f.at_dock() { + format!("Fly in close to {} to dock.", dock.name) + } else if fast { + format!("Too fast to dock. Slow below {LANDING_SPEED:.0}.") + } else { + return self.touch_down(ctx); + } + } else { + "There's nowhere to land out here.".to_string() + }; + self.say(refusal, ctx); + Transition::None + } + + /// Lands, or docks. Outside a capital you get up from the controls, + /// and the way into the city is at the other end of the ship; anywhere + /// else there's no city to walk to, so you're straight out at the dock, + /// or on the ground. + fn touch_down(&mut self, ctx: &mut Ctx) -> Transition { + self.stop(); + self.walking = Some(Walking::Landed); + ctx.landed = true; + Transition::Push(landed(ctx, self.place)) + } + + /// Gets up from the controls to walk about the ship, if you're going + /// slowly enough to leave it holding station. + fn get_up(&mut self, ctx: &mut Ctx) -> Transition { + if self.flight.speed() > LANDING_SPEED { + let text = format!("Too fast to leave the controls. Slow below {LANDING_SPEED:.0}."); + self.say(text, ctx); + return Transition::None; + } + let wreck = + self.flight.wreck_alongside().map(|i| self.flight.locale.wrecks[i].wreck.clone()); + self.stop(); + self.walking = Some(Walking::Adrift); + Transition::Push(Box::new(Aboard::adrift(&self.flight.locale.what, wreck))) + } + + fn stop(&mut self) { + self.flight.ship.vel = Vec2::ZERO; + self.throttle = 0; + self.braking = false; + self.pending_turn = 0.0; + } + + fn board(&mut self, ctx: &mut Ctx) -> Transition { + let Some(i) = self.flight.wreck_alongside() else { + let text = if self.flight.locale.wrecks.is_empty() { + "There are no wrecks here." + } else { + "Fly alongside a wreck to board it." + }; + self.say(text.to_string(), ctx); + return Transition::None; + }; + let wreck = &self.flight.locale.wrecks[i].wreck; + if self.flight.speed() > LANDING_SPEED { + let text = format!("Too fast to board. Slow below {LANDING_SPEED:.0}."); + self.say(text, ctx); + Transition::None + } else if ctx.stripped.contains(&wreck.seed) { + let text = format!("You've already stripped {}. Nothing's left aboard.", wreck.name); + self.say(text, ctx); + Transition::None + } else { + let seed = wreck.seed; + ctx.stripped.insert(seed); + self.stop(); + Transition::Push(Box::new(Derelict::board(seed, ctx))) + } + } + + fn next_target(&mut self) { + let l = &self.flight.locale; + let mut targets = vec![]; + if l.rocks.iter().any(|r| r.ore.is_some()) { + targets.push(Target::NearestOre); + } + if l.body.is_some() || l.dock.is_some() { + targets.push(Target::Middle); + } + targets.extend((0..l.wrecks.len()).map(Target::Wreck)); + self.target = match self.target.and_then(|t| targets.iter().position(|&x| x == t)) { + Some(i) if i + 1 < targets.len() => Some(targets[i + 1]), + Some(_) => None, + None => targets.first().copied(), + }; + } + + /// Where the target is and what to call it. + fn target_info(&self) -> Option<(Vec2, f64, String)> { + let l = &self.flight.locale; + let ship = self.flight.ship.pos; + match self.target? { + Target::NearestOre => { + let rock = l + .rocks + .iter() + .filter(|r| r.ore.is_some()) + .min_by(|a, b| a.pos.distance(ship).total_cmp(&b.pos.distance(ship)))?; + let size = match rock.size { + Size::Small => "small", + Size::Medium => "", + Size::Large => "large", + }; + let name = format!("{size} rock, {}", rock.ore.unwrap().name); + Some((rock.pos, rock.size.radius(), name.trim_start().to_string())) + } + Target::Middle => match (&l.body, &l.dock) { + (Some(b), _) => Some((Vec2::ZERO, b.radius, b.name.clone())), + (None, Some(d)) => Some((Vec2::ZERO, locale::DOCK_RADIUS, d.name.clone())), + (None, None) => None, + }, + Target::Wreck(i) => { + let w = l.wrecks.get(i)?; + Some((w.pos, 2.0, format!("wreck of {}", w.wreck.name))) + } + } + } +} + +/// The scenes that pick up a game where it was left: flying wherever you +/// are, and, if you'd set down there, aboard your ship on the ground. +/// Where a saved game picks up: flying, aboard your ship where it's landed, +/// or aboard the wreck you were in. +pub fn resume(ctx: &Ctx) -> Vec<Box<dyn Scene>> { + let mut flying = FlightScene::new(ctx); + let mut scenes: Vec<Box<dyn Scene>> = if ctx.landed { + // Where you'd have been to land: over the planet, or at the dock. + flying.flight.ship.pos = Vec2::ZERO; + flying.walking = Some(Walking::Landed); + let landed = if has_city(ctx, ctx.location) { + Box::new(Aboard::resumed(ctx, ctx.location)) + } else { + landed(ctx, ctx.location) + }; + vec![Box::new(flying), landed] + } else { + vec![Box::new(flying)] + }; + if let Some(wreck) = Derelict::resumed(ctx) { + scenes.push(Box::new(wreck)); + } + scenes +} + +fn has_city(ctx: &Ctx, place: usize) -> bool { + matches!(ctx.system.landing(place), Landing::Capital(_)) +} + +/// Where you are once you've set down at `place`: aboard, if there's a city +/// to walk out to, or else straight out at the dock or on the ground. +fn landed(ctx: &Ctx, place: usize) -> Box<dyn Scene> { + if has_city(ctx, place) { + Box::new(Aboard::landed(ctx, place)) + } else { + Box::new(Port::new(ctx, place)) + } +} + +fn arrive(ctx: &Ctx) -> Flight { + let locale = locale::generate(&ctx.system, ctx.location, ctx.day, ctx.departed, &ctx.stripped); + let seed = seed::derive(ctx.seed, "arrival", ctx.day.to_bits() ^ ctx.location as u64); + Flight::new(locale, seed) +} + +fn days_text(days: f64) -> String { + if days < 1.0 { + format!("{:.0} hours", days * 24.0) + } else if (days - 1.0).abs() < 0.05 { + "A day".to_string() + } else { + format!("{days:.1} days") + } +} + +impl Scene for FlightScene { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + if let Some(pause) = &mut self.pause { + return match pause.key(key) { + None => Transition::None, + Some(choice) => { + self.pause = None; + match choice { + Choice::Quit => Transition::Title, + Choice::Here | Choice::Resume => Transition::None, + } + } + }; + } + self.sit_down(ctx); + self.catch_up(ctx); + let repeat = key.kind == KeyEventKind::Repeat; + if !ctx.keyboard.key_release && self.key_fallback(key.code, ctx) { + return Transition::None; + } + if repeat { + return Transition::None; + } + match key.code { + KeyCode::Char(c) => match c.to_ascii_lowercase() { + 'l' => self.land(ctx), + 'b' => self.board(ctx), + 'e' => self.get_up(ctx), + 'm' => Transition::Push(Box::new(SystemMap::new(ctx))), + 'g' => Transition::Push(Box::new(GalaxyMap::new(ctx))), + 'c' => Transition::Push(Box::new(Sheet::new())), + 'r' => { + self.braking = true; + Transition::None + } + _ => Transition::None, + }, + KeyCode::Tab => { + self.next_target(); + Transition::None + } + KeyCode::Esc => { + let note = format!( + "You're {}. Everything holds still while this is open.", + self.flight.locale.what + ); + self.pause = Some(Pause::new(None, Some(note))); + Transition::None + } + _ => Transition::None, + } + } + + fn tick(&mut self, ctx: &mut Ctx) -> Transition { + if self.pause.is_some() { + return Transition::None; + } + self.sit_down(ctx); + self.catch_up(ctx); + let controls = self.controls(ctx); + self.flight.laser_power = trade::laser_power(ctx.lenses); + for event in self.flight.step(DT, controls, ctx.hold.ore_room()) { + let text = match event { + Event::Collected(ore) => { + *ctx.hold.ore.entry(ore.name).or_default() += 1; + format!("Scooped up a ton of {}. Hold: {} t.", ore.name, ore_line(&ctx.hold)) + } + Event::Shattered { ore: Some(ore), .. } => { + format!("The rock splits, spilling {ore}.") + } + Event::Shattered { ore: None, .. } => continue, + Event::HoldFull => { + "Your hold is full of ore. Land somewhere to sell it.".to_string() + } + }; + self.say(text, ctx); + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let area = frame.area(); + let (map, panel) = if area.width >= PANEL_MIN_SCREEN { + let [map, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(area); + (map, Some(panel)) + } else { + (area, None) + }; + let view = View { area: map, camera: self.flight.ship.pos }; + self.draw_map(frame.buffer_mut(), &view, ctx); + self.draw_messages(frame.buffer_mut(), map, ctx); + match panel { + Some(panel) => self.draw_panel(frame, panel, ctx), + None => self.draw_status_bar(frame.buffer_mut(), map, ctx), + } + if let Some(pause) = &self.pause { + pause.render(frame, area); + } + } +} + +impl FlightScene { + fn draw_map(&self, buf: &mut Buffer, view: &View, ctx: &Ctx) { + let f = &self.flight; + let l = &f.locale; + let look = seed::derive(ctx.system.seed, "look", l.place as u64); + sky::starfield(buf, view.area, view.camera, look); + + for (i, b) in l.backdrops.iter().enumerate() { + let at = b.pos + view.camera * (1.0 - BACKDROP_PARALLAX); + let surface = Surface { + rgb: b.kind.rgb, + banded: b.kind.banded, + lights: false, + ramp: sky::PLANET_RAMP, + seed: seed::derive(look, "backdrop", i as u64), + }; + let (col, row) = view.screen(at); + if !sky::sphere(buf, view.area, (col, row), b.radius, l.sun, &surface) { + view.put(buf, at, '·', sky::rgb(b.kind.rgb)); + } + let below = row + b.radius / 2.0 + 1.0; + sky::label(buf, view.area, col, below, &b.name, Color::Rgb(64, 66, 82)); + } + + self.draw_edge(buf, view); + + if let Some(body) = &l.body { + let surface = Surface { + rgb: body.kind.rgb, + banded: body.kind.banded, + lights: body.settled, + ramp: sky::PLANET_RAMP, + seed: look, + }; + let (col, row) = view.screen(Vec2::ZERO); + sky::sphere(buf, view.area, (col, row), body.radius, l.sun, &surface); + let below = row + body.radius / 2.0 + 1.0; + sky::label(buf, view.area, col, below, &body.name, DIM); + } + if let Some(dock) = &l.dock { + sky::draw_station(buf, view, dock.culture, ctx.ticks); + let (col, row) = view.screen(Vec2::ZERO); + sky::label(buf, view.area, col, row + 3.0, &dock.name, DIM); + } + + for w in &l.wrecks { + let wreck = &w.wreck; + let beacon = !ctx.stripped.contains(&wreck.seed) && wreck.calling(ctx.day); + sky::draw_wreck(buf, view, w.pos, w.heading, beacon, ctx.ticks); + if w.pos.distance(f.ship.pos) < 30.0 { + let (col, row) = view.screen(w.pos); + sky::label(buf, view.area, col, row + 1.0, &wreck.name, DIM); + } + } + + for rock in &l.rocks { + let mut rgb = ROCK; + if let Some(ore) = rock.ore { + rgb = mix(rgb, ore.rgb, 0.55); + } + // Glows hotter as the laser works on it. + let worn = 1.0 - rock.integrity / rock.size.toughness(); + rgb = mix(rgb, (255, 150, 90), worn * 0.8); + let surface = + Surface { rgb, banded: false, lights: false, ramp: sky::ROCK_RAMP, seed: 0 }; + let drawn = rock.size != Size::Small + && sky::sphere( + buf, + view.area, + view.screen(rock.pos), + rock.size.radius(), + l.sun, + &surface, + ); + if !drawn { + view.put(buf, rock.pos, 'o', sky::shade(rgb, 1.0)); + } + } + + for bit in &f.flotsam { + let fading = FLOTSAM_LIFE - bit.age < 8.0 && ctx.ticks % 10 < 5; + if !fading { + view.put(buf, bit.pos, '*', sky::shade(bit.ore.rgb, 1.15)); + } + } + + for spark in &f.sparks { + let k = spark.age / SPARK_LIFE; + let colour = mix((255, 226, 150), (150, 50, 30), k); + view.put(buf, spark.pos, if k < 0.5 { '∙' } else { '·' }, sky::rgb(colour)); + } + + if let Some(beam) = &f.beam { + let length = beam.from.distance(beam.to); + let dir = (beam.to - beam.from).normalised(); + let steps = (length * 2.0) as usize; + for i in 0..steps { + view.put(buf, beam.from + dir * (i as f64 * 0.5), '·', BEAM); + } + if beam.hit { + let glyph = if ctx.ticks % 4 < 2 { '*' } else { '+' }; + view.put(buf, beam.to, glyph, Color::Rgb(255, 236, 200)); + } + } + + if let Some((pos, r, _)) = self.target_info() { + let reach = r + 1.5; + view.put(buf, Vec2::new(pos.x - reach, pos.y), '[', ACCENT); + view.put(buf, Vec2::new(pos.x + reach, pos.y), ']', ACCENT); + } + + sky::draw_ship(buf, view, &f.ship, ctx.ticks); + } + + /// Faint dots along the edge of the map, where it starts pushing back. + fn draw_edge(&self, buf: &mut Buffer, view: &View) { + let e = self.flight.locale.extent; + let colour = Color::Rgb(56, 58, 74); + let a = view.area; + let (half_w, half_h) = (f64::from(a.width) / 2.0 + 1.0, f64::from(a.height) + 2.0); + let (cx, cy) = (view.camera.x, view.camera.y); + for x in [-e, e] { + if (x - cx).abs() <= half_w { + let (from, to) = ((cy - half_h).max(-e), (cy + half_h).min(e)); + let mut y = (from / 2.0).ceil() * 2.0; + while y <= to { + view.put(buf, Vec2::new(x, y), '┊', colour); + y += 2.0; + } + } + } + for y in [-e, e] { + if (y - cy).abs() <= half_h { + let (from, to) = ((cx - half_w).max(-e), (cx + half_w).min(e)); + let mut x = from.ceil(); + while x <= to { + view.put(buf, Vec2::new(x, y), '┈', colour); + x += 1.0; + } + } + } + } + + fn draw_messages(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let fresh: Vec<&String> = self + .messages + .iter() + .filter(|(_, at)| { + ctx.ticks.saturating_sub(*at) < MESSAGE_SECONDS * u64::from(TICKS_PER_SECOND) + }) + .map(|(t, _)| t) + .collect(); + let shown = fresh.iter().rev().take(3).rev(); + let count = fresh.len().min(3) as u16; + let first = area.bottom().saturating_sub(count + 1); + if self.flight.past_edge() { + let warning = "You're leaving the vicinity. [m] opens the map, to travel on."; + text_at(buf, area, first.saturating_sub(1), warning, ACCENT); + } + for (row, text) in (first..).zip(shown) { + text_at(buf, area, row, text, Color::Rgb(220, 220, 230)); + } + } + + fn draw_status_bar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let text = format!( + " {} · day {:.1} · speed {:.0} · ore {} · fuel {} ", + self.flight.locale.name, + ctx.day, + self.flight.speed(), + ore_line(&ctx.hold), + ctx.hold.fuel + ); + let mut line = keys::line(&[("m", "map"), ("g", "stars"), ("esc", "menu")]); + line.spans.insert(0, Span::raw(text).fg(ACCENT)); + let bar = Rect { height: 1, ..area }; + buf.set_style(bar, Style::new().bg(Color::Rgb(20, 20, 30))); + buf.set_line(area.x, area.y, &line, area.width); + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let l = &self.flight.locale; + let block = Block::bordered() + .title(format!(" {} ", l.name)) + .border_style(Style::new().fg(DIM)) + .title_style(Style::new().fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let hints: &[(&str, &str)] = if ctx.keyboard.key_release { + &[ + ("↑ w", "thrust"), + ("← → a d", "turn"), + ("↓ s r", "brake"), + ("space f", "laser"), + ("l", "land, dock"), + ("b", "board"), + ("e", "get up"), + ("tab", "target"), + ("m", "system map"), + ("g", "star map"), + ("c", "character"), + ("esc", "menu: save and quit"), + ] + } else { + &[ + ("↑ ↓ w s", "throttle"), + ("← → a d", "turn"), + ("r", "brake"), + ("x", "cut engines"), + ("space f", "laser"), + ("b", "board"), + ("l", "land, dock"), + ("e", "get up"), + ("tab", "target"), + ("c", "character"), + ("m", "system map"), + ("g", "star map"), + ("esc", "menu: save and quit"), + ] + }; + let hints = keys::packed(hints, inner.width); + // What's left after the text, up to a square the width of the panel. + let text_rows = 4 + 6 + 3 + 1 + hints.len() as u16; + let radar_rows = inner.height.saturating_sub(text_rows).min(inner.width / 2).min(13); + let [about, radar, status, target, _, keys] = Layout::vertical([ + Constraint::Length(4), + Constraint::Length(radar_rows), + Constraint::Length(6), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(hints.len() as u16), + ]) + .areas(inner); + + let about_lines = vec![ + Line::from(l.what.clone()).fg(DIM), + Line::from(format!("{} system, day {:.1}", ctx.system.name, ctx.day)).fg(DIM), + ]; + frame.render_widget(Paragraph::new(about_lines).wrap(Wrap { trim: true }), about); + + if radar.height >= 3 { + self.draw_radar(frame.buffer_mut(), radar, ctx); + } + + let f = &self.flight; + let mut lines = vec![Line::from(vec![ + Span::raw("speed ").fg(DIM), + Span::raw(format!("{:>4.1}", f.speed())), + if f.speed() <= LANDING_SPEED { + Span::raw(" · can land").fg(DIM) + } else { + Span::raw("") + }, + ])]; + if !ctx.keyboard.key_release { + let bars: String = + (1..=THROTTLE_STEPS).map(|i| if i <= self.throttle { '▮' } else { '▯' }).collect(); + lines.push(Line::from(vec![Span::raw("thrust ").fg(DIM), Span::raw(bars).fg(ACCENT)])); + } + if self.braking { + lines.push(Line::from("braking").fg(ACCENT)); + } + let h = &ctx.hold; + lines.push(Line::from(vec![Span::raw("ore ").fg(DIM), Span::raw(ore_line(h))])); + let gauge: String = + (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect(); + lines.push(Line::from(vec![ + Span::raw("fuel ").fg(DIM), + Span::raw(gauge).fg(if h.fuel == 0 { Color::Rgb(214, 112, 100) } else { FUEL }), + ])); + lines.push(Line::from(vec![ + Span::raw("hold ").fg(DIM), + Span::raw(format!("{} artifacts, {} ¤", h.artifacts(), h.scrip)), + ])); + frame.render_widget(Paragraph::new(lines), status); + + let target_lines = match self.target_info() { + Some((pos, _, name)) => { + let off = pos - f.ship.pos; + vec![ + Line::from(name).fg(ACCENT), + Line::from(format!("{} {:.0} away", sky::arrow(off.heading()), off.length())), + ] + } + None if self.target == Some(Target::NearestOre) => { + vec![Line::from("no ore in sight").fg(DIM)] + } + None => vec![keys::line(&[("tab", "pick a target")])], + }; + frame.render_widget(Paragraph::new(target_lines), target); + + frame.render_widget(Paragraph::new(hints), keys); + } + + /// The whole local map, shrunk to fit. + fn draw_radar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let l = &self.flight.locale; + let w = f64::from(area.width.min(area.height * 2)); + let h = w / 2.0; + let left = f64::from(area.x) + (f64::from(area.width) - w) / 2.0; + let top = f64::from(area.y); + let e = l.extent; + let cell = |p: Vec2| { + let col = left + (p.x + e) / (2.0 * e) * w; + let row = top + (p.y + e) / (2.0 * e) * h; + let inside = col >= left && col < left + w && row >= top && row < top + h; + inside.then_some((col as u16, row as u16)) + }; + for row in 0..h as u16 { + for col in 0..w as u16 { + buf[(left as u16 + col, area.y + row)].set_char('·').set_fg(Color::Rgb(40, 42, 54)); + } + } + let mut put = |p: Vec2, ch: char, fg: Color| { + if let Some(pos) = cell(p) { + buf[pos].set_char(ch).set_fg(fg); + } + }; + if let Some(body) = &l.body { + let step = 2.0 * e / w; + let mut y = -body.radius; + while y <= body.radius { + let mut x = -body.radius; + while x <= body.radius { + if x * x + y * y <= body.radius * body.radius { + put(Vec2::new(x, y), 'o', sky::shade(body.kind.rgb, 0.7)); + } + x += step; + } + y += step * 2.0; + } + } + if l.dock.is_some() { + put(Vec2::ZERO, '#', ACCENT); + } + for rock in &l.rocks { + let fg = rock.ore.map_or(Color::Rgb(110, 104, 96), |o| sky::rgb(o.rgb)); + put(rock.pos, '∙', fg); + } + for bit in &self.flight.flotsam { + put(bit.pos, '*', sky::rgb(bit.ore.rgb)); + } + for wreck in &l.wrecks { + let stripped = ctx.stripped.contains(&wreck.wreck.seed); + put(wreck.pos, '≡', if stripped { HULL } else { Color::Rgb(255, 90, 80) }); + } + let ship = &self.flight.ship; + put(ship.pos, sky::arrow(ship.heading), Color::White); + } +} + +/// One line of text from the left of `area`, a cell in, clipped to fit. +fn text_at(buf: &mut Buffer, area: Rect, row: u16, text: &str, fg: Color) { + if row < area.y || row >= area.bottom() || area.width < 3 { + return; + } + let text: String = text.chars().take(usize::from(area.width - 2)).collect(); + buf.set_string(area.x + 1, row, text, Style::new().fg(fg)); +} + +/// "12/40 t". +fn ore_line(hold: &Hold) -> String { + format!("{}/{} t", hold.ore_tons(), hold.ore_capacity) +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + use crate::space::system::PlaceKind; + + fn press(scene: &mut FlightScene, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn ticks(scene: &mut FlightScene, ctx: &mut Ctx, n: u32) { + for _ in 0..n { + ctx.ticks += 1; + scene.tick(ctx); + } + } + + #[test] + fn esc_holds_everything_still_and_esc_again_carries_on() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + for _ in 0..4 { + press(&mut scene, &mut ctx, KeyCode::Up); + } + ticks(&mut scene, &mut ctx, 5); + press(&mut scene, &mut ctx, KeyCode::Esc); + let held = scene.flight.ship.pos; + ticks(&mut scene, &mut ctx, 30); + assert_eq!(scene.flight.ship.pos, held, "nothing moves while the menu's open"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None)); + ticks(&mut scene, &mut ctx, 5); + assert_ne!(scene.flight.ship.pos, held, "and carries on after"); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('2')), Transition::Title)); + } + + #[test] + fn the_throttle_flies_you_forward_and_brake_stops_you() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let start = scene.flight.ship.pos; + for _ in 0..4 { + press(&mut scene, &mut ctx, KeyCode::Up); + } + ticks(&mut scene, &mut ctx, 15); + assert!(scene.flight.speed() > 5.0); + assert!(scene.flight.ship.pos.distance(start) > 1.0); + press(&mut scene, &mut ctx, KeyCode::Char('x')); + press(&mut scene, &mut ctx, KeyCode::Char('r')); + ticks(&mut scene, &mut ctx, 150); + assert!(scene.flight.speed() < 0.5, "braked to {}", scene.flight.speed()); + assert!(!scene.braking); + } + + #[test] + fn turn_presses_swing_you_round_an_eighth_of_a_half_turn() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let before = scene.flight.ship.heading; + press(&mut scene, &mut ctx, KeyCode::Right); + ticks(&mut scene, &mut ctx, 10); + let turned = crate::space::turn_between(before, scene.flight.ship.heading); + assert!((turned - TURN_STEP).abs() < 1e-9, "turned {turned}"); + } + + #[test] + fn travelling_on_the_map_rebuilds_the_place() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + ctx.location = belt; + ctx.day = 14.0; + ticks(&mut scene, &mut ctx, 1); + assert_eq!(scene.place, belt); + assert!(scene.flight.locale.body.is_none() && scene.flight.locale.rocks.len() > 50); + assert!(scene.messages.last().unwrap().0.contains("14.0 days later")); + } + + #[test] + fn jumping_brings_you_out_at_the_edge_of_another_system() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let mut map = GalaxyMap::new(&ctx); + map.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx); + map.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut ctx); + ticks(&mut scene, &mut ctx, 1); + assert_eq!((scene.star, scene.place), (ctx.star, ctx.system.edge_place())); + let said = &scene.messages.last().unwrap().0; + assert!(said.contains(&format!("edge of the {} system", ctx.system.name)), "{said}"); + assert!(!said.contains("{where}"), "{said}"); + assert!(ctx.arrival_note.is_none()); + } + + #[test] + fn you_land_only_over_the_planet_and_slowly() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None)); + assert!(scene.messages.last().unwrap().0.starts_with("Fly over")); + scene.flight.ship.pos = Vec2::ZERO; + scene.flight.ship.vel = Vec2::new(20.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None)); + assert!(scene.messages.last().unwrap().0.starts_with("Too fast")); + scene.flight.ship.vel = Vec2::new(2.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Push(_))); + // Walking back to the pilot's seat pops the ship's inside off, and + // the next tick finds you back at the controls. + ticks(&mut scene, &mut ctx, 1); + assert!(scene.messages.last().unwrap().0.starts_with("You lift off from")); + } + + #[test] + fn without_a_city_you_land_straight_at_the_dock() { + let mut ctx = test_ctx(); + let station = + ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap(); + ctx.location = station; + let mut scene = FlightScene::new(&ctx); + let Transition::Push(mut docked) = scene.touch_down(&mut ctx) else { panic!("docks") }; + let l = KeyEvent::from(KeyCode::Char('l')); + assert!(matches!(docked.key(l, &mut ctx), Transition::Pop), "l undocks, from the dock"); + ticks(&mut scene, &mut ctx, 1); + assert!(scene.messages.last().unwrap().0.starts_with("You undock from")); + assert!(!ctx.landed); + + // A game saved there picks up there too. + ctx.landed = true; + let mut stack = resume(&ctx); + assert!(matches!(stack[1].key(l, &mut ctx), Transition::Pop)); + } + + #[test] + fn you_pick_up_where_you_were_landed_or_flying() { + let mut ctx = test_ctx(); + assert_eq!(resume(&ctx).len(), 1, "flying"); + let mut scene = FlightScene::new(&ctx); + scene.flight.ship.pos = Vec2::ZERO; + press(&mut scene, &mut ctx, KeyCode::Char('l')); + assert!(ctx.landed); + let mut stack = resume(&ctx); + assert_eq!(stack.len(), 2, "aboard, landed"); + ticks(&mut scene, &mut ctx, 1); + assert!(!ctx.landed, "taking off"); + + // Taking off from a game picked up landed is from where you landed. + ctx.landed = true; + stack[0].tick(&mut ctx); + assert!(!ctx.landed); + } + + #[test] + fn you_get_up_from_the_controls_only_when_slow() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + scene.flight.ship.vel = Vec2::new(20.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::None)); + assert!(scene.messages.last().unwrap().0.starts_with("Too fast")); + scene.flight.ship.vel = Vec2::new(2.0, 0.0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::Push(_))); + assert_eq!(scene.flight.speed(), 0.0, "the ship holds station"); + ticks(&mut scene, &mut ctx, 1); + assert!(scene.messages.last().unwrap().0.contains("take the controls")); + } + + #[test] + fn scooped_ore_goes_in_the_hold() { + let mut ctx = test_ctx(); + let mut scene = FlightScene::new(&ctx); + let ore = &locale::ORES[2]; + let pos = scene.flight.ship.pos; + scene.flight.flotsam.push(crate::space::flight::Flotsam { + pos, + vel: Vec2::ZERO, + ore, + age: 0.0, + }); + ticks(&mut scene, &mut ctx, 1); + assert_eq!(ctx.hold.ore.get("iridium"), Some(&1)); + } + + #[test] + fn boarding_a_wreck_strips_it() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + ctx.location = wreck.place; + let mut scene = FlightScene::new(&ctx); + let w = scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).unwrap(); + scene.flight.ship.pos = w.pos; + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::Push(_))); + assert!(ctx.stripped.contains(&wreck.seed)); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::None)); + assert!(scene.messages.last().unwrap().0.contains("already stripped")); + // The hulk stays while you're here, and is gone when you come back. + let again = FlightScene::new(&ctx); + assert!(again.flight.locale.wrecks.iter().all(|w| w.wreck != wreck)); + } + + #[test] + fn wrecks_you_left_alone_drift_off_in_time() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + ctx.location = wreck.place; + let here = |ctx: &Ctx| { + let scene = FlightScene::new(ctx); + scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).map(|w| w.pos) + }; + let first = here(&ctx).expect("calling today"); + ctx.day = (ctx.day + wreck.leaves) / 2.0; + assert_eq!(here(&ctx), Some(first), "still there, in the same spot"); + ctx.day = wreck.leaves + 0.5; + assert_eq!(here(&ctx), None, "its beacon failed, and it's gone"); + } + + #[test] + fn a_wreck_you_set_out_for_waits_for_you() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + let elsewhere = (0..ctx.system.places.len()).find(|&p| p != wreck.place).unwrap(); + ctx.location = elsewhere; + let here = |ctx: &Ctx| { + let scene = FlightScene::new(ctx); + scene.flight.locale.wrecks.iter().any(|w| w.wreck == wreck) + }; + // A trip so slow that its beacon fails on the way. + ctx.travel(wreck.place, wreck.leaves - ctx.day + 1.0); + assert!(!wreck.calling(ctx.day)); + assert!(here(&ctx), "you came by its last fix"); + assert!(ctx.wrecks().contains(&wreck), "and the map knows it's here"); + ctx.day += 3.0; + assert!(here(&ctx), "it stays while you do"); + ctx.travel(elsewhere, 1.0); + assert!(!ctx.wrecks().contains(&wreck)); + ctx.travel(wreck.place, 1.0); + assert!(!here(&ctx), "come back another time and it's gone"); + } +} diff --git a/src/scenes/forge.rs b/src/scenes/forge.rs index f856c8f..a49e098 100644 --- a/src/scenes/forge.rs +++ b/src/scenes/forge.rs @@ -10,6 +10,7 @@ use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Wrap}; +use super::keys; use super::{Scene, Transition}; use crate::app::Ctx; use crate::names::{self, Artifact, Culture}; @@ -51,7 +52,8 @@ impl Forge { } } -fn colour(culture: Culture) -> Color { +/// Each people's colour, wherever they're shown. +pub fn colour(culture: Culture) -> Color { match culture { Culture::Qaari => Color::Yellow, Culture::Cant => Color::Red, @@ -147,7 +149,11 @@ impl Scene for Forge { frame.render_widget(Paragraph::new(lines).block(block), artifacts); frame.render_widget( - Paragraph::new(" [space/→] forge again [←] previous [esc] back").dark_gray(), + Paragraph::new(keys::line(&[ + ("space →", "forge again"), + ("←", "previous"), + ("esc", "back"), + ])), footer, ); } 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<System>, + /// What happened when you last tried to go somewhere. + report: Option<String>, +} + +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<Vec<usize>> { + 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<usize> = 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::<Vec<_>>(), + ), + ]; + 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<Line<'static>> { + 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<String> = 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<String> = + 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<Line<'static>> { + 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<String> = (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<String> = 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::<Vec<_>>(); + 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"); + } +} diff --git a/src/scenes/inventory.rs b/src/scenes/inventory.rs new file mode 100644 index 0000000..d9dd249 --- /dev/null +++ b/src/scenes/inventory.rs @@ -0,0 +1,1808 @@ +//! The inventory, after Cataclysm's: everything on you, grouped by what it +//! is, with all about whatever's picked out; and beside it, when there is +//! one, another pile to move things to and from: your ship's lockers, what's +//! on the floor where you stand, or an armourer's rack to buy from and sell +//! to, with your armour to mend. +//! +//! Vim keys throughout: `j` `k` move, `gg` `G` go to the top and bottom, +//! ctrl-d and ctrl-u by half a page, `h` `l` between the lists, `/` finds. +//! `o` sorts both lists by something else (`Sort`), within each kind of +//! thing, and `O` turns the sort round. Every row shows what it weighs and +//! what it would fetch. +//! A count before a key does it that many times: `3d` drops three, `D` the +//! whole stack. `enter` lists everything you can do with what's picked out. +//! +//! It isn't a scene of its own. The scene you're walking about in opens it +//! over itself and carries out what it asks for (`Command`), since only +//! that scene knows what putting something down means there, and whether +//! it takes a turn. + +use std::cell::Cell; +use std::collections::BTreeSet; + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; +use serde::{Deserialize, Serialize}; + +use super::keys; +use super::sky::rgb; +use crate::dungeon::armour::Armor; +use crate::dungeon::arms::{Condition, Dmg, Weapon}; +use crate::dungeon::crawl::{Affinity, MEDPATCH_HEAL, MONSTERS}; +use crate::items::{self, Category, Gear, Item, LOCKER_CAPACITY, Slot, Spot, Stack}; +use crate::space::system::Rgb; +use crate::trade::{self, Terms}; + +const ACCENT: Color = Color::Rgb(240, 200, 110); +/// What something would fetch somewhere else: about this. +const GUESS: Color = Color::Rgb(184, 166, 120); +const DIM: Color = Color::Rgb(120, 124, 142); +const HEADING: Color = Color::Rgb(160, 166, 190); +/// Wider than this, the details sit beside the lists rather than below. +const WIDE: u16 = 110; +/// A list narrower than this shows what things fetch, but not what they +/// weigh. +const NARROW: usize = 30; +/// How far in things sit from the heading they're under. +const INDENT: &str = " "; +const DETAILS_HEIGHT: u16 = 13; + +/// How something looks lying about: a glyph and a colour. +pub fn look(item: &Item) -> (char, Rgb) { + match item { + Item::Weapon(w) => (if w.is_gun() { '}' } else { ')' }, w.dmg.rgb()), + Item::Armor(_) => ('[', (170, 182, 204)), + Item::Food(_) => ('%', (214, 176, 112)), + Item::Medpatch => ('!', (240, 96, 96)), + Item::Artifact(_) => ('*', (255, 214, 92)), + Item::Salvage(_) => ('(', (150, 200, 222)), + } +} + +/// The pile beside you. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Beside { + /// Your ship's lockers. + Lockers, + /// The floor where you stand: "on the floor", "on the deck". + Floor(&'static str), + /// An armourer's rack: what's for sale (see `View::shop`). + Rack, +} + +/// The armourer you're trading with, whose rack is the pile beside you. +#[derive(Clone, Copy)] +pub struct Counter<'a> { + pub terms: Terms, + /// What's in your ship's lockers: they'll buy from there too. + pub aboard: &'a [Stack], + pub scrip: u32, +} + +/// What the lists are sorted by, within each kind of thing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SortBy { + #[default] + Name, + Weight, + /// What it fetches, or here costs. + Price, + /// Price for its weight. + Density, + /// Armour, most worn first; the rest after. + Durability, + /// Weapons by their average damage, armour by what it guards. + Strength, + /// The order you came by things in. + Newest, +} + +impl SortBy { + const ALL: [SortBy; 7] = [ + SortBy::Name, + SortBy::Weight, + SortBy::Price, + SortBy::Density, + SortBy::Durability, + SortBy::Strength, + SortBy::Newest, + ]; + + /// The sort after this one, round to the first. + fn next(self) -> SortBy { + let i = SortBy::ALL.iter().position(|&s| s == self).unwrap_or(0); + SortBy::ALL[(i + 1) % SortBy::ALL.len()] + } +} + +/// How the lists are sorted: by what, and which way round. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sort { + pub by: SortBy, + /// The other way round from usual: names z–a, the lightest first, the + /// cheapest first... + #[serde(default)] + pub reversed: bool, +} + +impl Sort { + /// How it reads: "heaviest first", "by name". + pub fn says(self) -> &'static str { + match (self.by, self.reversed) { + (SortBy::Name, false) => "by name", + (SortBy::Name, true) => "by name, z–a", + (SortBy::Weight, false) => "heaviest first", + (SortBy::Weight, true) => "lightest first", + (SortBy::Price, false) => "dearest first", + (SortBy::Price, true) => "cheapest first", + (SortBy::Density, false) => "most ¤/kg first", + (SortBy::Density, true) => "least ¤/kg first", + (SortBy::Durability, false) => "most worn first", + (SortBy::Durability, true) => "least worn first", + (SortBy::Strength, false) => "strongest first", + (SortBy::Strength, true) => "weakest first", + (SortBy::Newest, false) => "newest first", + (SortBy::Newest, true) => "oldest first", + } + } +} + +/// What something would fetch, or cost, as a row shows it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Price { + /// What the armourer you're at asks or pays, and whether you can pay. + Here(u32, bool), + /// About what a buyer elsewhere would pay. + About(u32), + /// A scholar has yet to tell you what it is. + Unknown, + /// No one buys it. + Nothing, +} + +impl Price { + fn amount(self) -> Option<u32> { + match self { + Price::Here(n, _) | Price::About(n) => Some(n), + Price::Unknown | Price::Nothing => None, + } + } +} + +/// What a scene shows the inventory, each time it asks it anything. +#[derive(Clone, Copy)] +pub struct View<'a> { + pub gear: &'a Gear, + /// The pile beside you, if there's one, and what's in it. + pub beside: Option<(Beside, &'a [Stack])>, + /// How you know monsters take each kind of damage. + pub known: &'a BTreeSet<(&'static str, Dmg)>, + /// Whether you can be hurt here, and so use a medpatch: only in a wreck. + pub hurt: bool, + /// The shop you're trading with, if you're at an armourer's. + pub shop: Option<Counter<'a>>, + /// How the lists are sorted. + pub sort: Sort, +} + +/// What the inventory asks the scene to do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Command { + /// Ready the weapon at this index in your pack. + Ready(usize), + /// Put on the armour at this index in your pack. + Wear(usize), + TakeOff(Slot), + /// Eat one of the food at this index in your pack. + Eat(usize), + UseMedpatch, + /// Put down this many of what's at a spot on you, where you stand. + Drop(Spot, u32), + /// Put this many of what's at a spot on you in the lockers. + Stow(Spot, u32), + /// Take this many of the `n`th stack in the pile beside you. + Fetch(usize, u32), + /// Buy the `n`th thing on the rack. + Buy(usize), + /// Sell what's at a spot on you. + Sell(Spot), + /// Sell the `n`th stack in your ship's lockers. + SellAboard(usize), + /// Have the armour at a spot on you mended. + Mend(Spot), + /// Have the armour that's the `n`th stack in your ship's lockers mended. + MendAboard(usize), + /// Have all your armour mended, on you and aboard. + MendAll, +} + +/// How the inventory answers a key. +#[derive(Debug, PartialEq, Eq)] +pub enum Reply { + Stay, + Close, + Do(Command), + /// Sort the lists this way from now on. Takes no time. + Sort(Sort), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Row { + Heading(&'static str), + /// Something on you, or a slot to wear something in with nothing in it. + You(Spot), + /// The `n`th stack in the pile beside you. + Beside(usize), + /// The `n`th stack in your ship's lockers, when you're selling. + Aboard(usize), +} + +/// One thing you can do with what's picked out. +struct Act { + key: char, + what: String, + command: Command, +} + +pub struct Inventory { + /// Whether the cursor is in the pile beside you, rather than on you. + on_beside: bool, + /// Where the cursor is in each list, counting only rows you can pick. + cursor: [usize; 2], + /// A count typed before a key. + count: Option<u32>, + /// `g` pressed once, waiting for a second. + g: bool, + /// Typing what to find, after `/`. + finding: bool, + filter: String, + /// Picking from what you can do with what's picked out, on `enter`. + menu: Option<usize>, + /// What happened last. + pub note: Option<String>, + /// How many rows the lists showed when last drawn, for paging. + page: Cell<usize>, +} + +impl Default for Inventory { + fn default() -> Inventory { + Inventory::new() + } +} + +impl Inventory { + pub fn new() -> Inventory { + Inventory { + on_beside: false, + cursor: [0, 0], + count: None, + g: false, + finding: false, + filter: String::new(), + menu: None, + note: None, + page: Cell::new(10), + } + } + + /// Opens with the cursor on the pile beside you: to pick things up. + pub fn beside() -> Inventory { + Inventory { on_beside: true, ..Inventory::new() } + } + + fn matches(&self, item: &Item) -> bool { + self.filter.is_empty() + || item.label().to_lowercase().contains(&self.filter.to_lowercase()) + || item.category().heading().to_lowercase().contains(&self.filter.to_lowercase()) + } + + /// The rows of one list, headings and all. + fn rows(&self, view: &View, beside: bool) -> Vec<Row> { + let mut rows = Vec::new(); + let pile: Vec<(Row, &Stack)> = if beside { + let Some((_, stacks)) = view.beside else { return rows }; + stacks.iter().enumerate().map(|(i, s)| (Row::Beside(i), s)).collect() + } else { + let g = view.gear; + let mut worn = Vec::new(); + for slot in Slot::ALL { + match g.worn.get(&slot) { + Some(a) if self.matches(&Item::Armor(a.clone())) => { + worn.push(Row::You(Spot::Worn(slot))); + } + None if self.filter.is_empty() => worn.push(Row::You(Spot::Worn(slot))), + _ => {} + } + } + if !worn.is_empty() { + rows.push(Row::Heading("WORN")); + rows.extend(worn); + } + let ready: Vec<Row> = [(Spot::Melee, &g.melee), (Spot::Gun, &g.gun)] + .into_iter() + .filter(|(_, w)| self.matches(&Item::Weapon((*w).clone()))) + .map(|(spot, _)| Row::You(spot)) + .collect(); + if !ready.is_empty() { + rows.push(Row::Heading("READY")); + rows.extend(ready); + } + g.pack.iter().enumerate().map(|(i, s)| (Row::You(Spot::Pack(i)), s)).collect() + }; + for category in Category::ALL { + let mut these: Vec<&(Row, &Stack)> = pile + .iter() + .filter(|(_, s)| s.item.category() == category && self.matches(&s.item)) + .collect(); + if these.is_empty() { + continue; + } + these.sort_by(|a, b| order(view, a, b)); + rows.push(Row::Heading(category.heading())); + rows.extend(these.into_iter().map(|(row, _)| *row)); + } + if !beside && let Some(shop) = &view.shop { + let mut aboard: Vec<(Row, &Stack)> = shop + .aboard + .iter() + .enumerate() + .filter(|(_, s)| shop.terms.offer(&s.item).is_some() && self.matches(&s.item)) + .map(|(i, s)| (Row::Aboard(i), s)) + .collect(); + aboard.sort_by(|a, b| order(view, a, b)); + let aboard: Vec<Row> = aboard.into_iter().map(|(row, _)| row).collect(); + if !aboard.is_empty() { + rows.push(Row::Heading("IN YOUR SHIP")); + rows.extend(aboard); + } + } + rows + } + + /// The rows you can pick in one list. + fn picks(&self, view: &View, beside: bool) -> Vec<Row> { + self.rows(view, beside).into_iter().filter(|r| !matches!(r, Row::Heading(_))).collect() + } + + fn side(&self, view: &View) -> bool { + self.on_beside && view.beside.is_some() + } + + /// The row picked out, if there's anything to pick. + fn picked(&self, view: &View) -> Option<Row> { + let side = self.side(view); + let picks = self.picks(view, side); + let i = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1)); + picks.get(i).copied() + } + + fn stack(view: &View, row: Row) -> Option<Stack> { + match row { + Row::Heading(_) => None, + Row::You(spot) => view.gear.get(spot), + Row::Beside(i) => view.beside.and_then(|(_, s)| s.get(i)).cloned(), + Row::Aboard(i) => view.shop.as_ref().and_then(|s| s.aboard.get(i)).cloned(), + } + } + + /// Everything you can do with what's picked out, with `n` as the count. + fn acts(view: &View, row: Row, n: u32) -> Vec<Act> { + let mut acts = Vec::new(); + let Some(stack) = Self::stack(view, row) else { return acts }; + let mut act = |key, what: String, command| acts.push(Act { key, what, command }); + let some = n.clamp(1, stack.count); + let how_many = |verb: &str| match (stack.count, some) { + (1, _) => format!("{verb} it"), + (_, 1) => format!("{verb} one"), + (_, n) => format!("{verb} {n}"), + }; + let offer = view.shop.as_ref().and_then(|s| s.terms.offer(&stack.item)); + match row { + Row::Heading(_) => {} + Row::You(spot) => { + match (spot, &stack.item) { + (Spot::Pack(i), Item::Weapon(_)) => { + act('w', "ready it".into(), Command::Ready(i)) + } + (Spot::Pack(i), Item::Armor(_)) => act('W', "wear it".into(), Command::Wear(i)), + (Spot::Worn(slot), _) => { + act('T', "take it off".into(), Command::TakeOff(slot)); + } + (Spot::Pack(i), Item::Food(_)) => act('E', "eat one".into(), Command::Eat(i)), + (Spot::Pack(_), Item::Medpatch) if view.hurt => { + act('a', "use one".into(), Command::UseMedpatch); + } + _ => {} + } + // A ready weapon goes only if there's another to take its place. + let spared = match spot { + Spot::Melee => view.gear.spare(false).is_some(), + Spot::Gun => view.gear.spare(true).is_some(), + _ => true, + }; + if let Some(price) = offer.filter(|_| spared) { + act('s', format!("sell it for {price} ¤"), Command::Sell(spot)); + } + if let Some(cost) = mend_cost(view, &stack.item) { + act('r', format!("mend it for {cost} ¤"), Command::Mend(spot)); + } + if spared { + act('d', how_many("drop"), Command::Drop(spot, some)); + if stack.count > 1 { + act('D', "drop them all".into(), Command::Drop(spot, stack.count)); + } + if let Some((Beside::Lockers, _)) = view.beside { + act('m', how_many("stow"), Command::Stow(spot, some)); + if stack.count > 1 { + act('M', "stow them all".into(), Command::Stow(spot, stack.count)); + } + } + } + } + Row::Beside(i) => match (view.beside, &view.shop) { + (Some((Beside::Rack, _)), Some(shop)) => { + if let Some(price) = shop.terms.asking(&stack.item) { + act('b', format!("buy it for {price} ¤"), Command::Buy(i)); + } + } + _ => { + act('m', how_many("take"), Command::Fetch(i, some)); + if stack.count > 1 { + act('M', "take them all".into(), Command::Fetch(i, stack.count)); + } + } + }, + Row::Aboard(i) => { + if let Some(price) = offer { + act('s', format!("sell it for {price} ¤"), Command::SellAboard(i)); + } + if let Some(cost) = mend_cost(view, &stack.item) { + act('r', format!("mend it for {cost} ¤"), Command::MendAboard(i)); + } + } + } + if !matches!(row, Row::Beside(_)) + && let Some((n, cost)) = mend_all_cost(view) + && n > 1 + && matches!(stack.item, Item::Armor(_)) + { + act('R', format!("mend all {n} for {cost} ¤"), Command::MendAll); + } + acts + } + + fn move_by(&mut self, view: &View, by: isize) { + let side = self.side(view); + let last = self.picks(view, side).len().saturating_sub(1); + let cursor = &mut self.cursor[usize::from(side)]; + *cursor = (*cursor).min(last).saturating_add_signed(by).min(last); + } + + pub fn key(&mut self, key: KeyEvent, view: &View) -> Reply { + if self.finding { + match key.code { + KeyCode::Enter => self.finding = false, + KeyCode::Esc => { + self.finding = false; + self.filter.clear(); + } + KeyCode::Backspace if self.filter.is_empty() => self.finding = false, + KeyCode::Backspace => { + self.filter.pop(); + } + KeyCode::Char(c) => self.filter.push(c), + _ => {} + } + self.cursor = [0, 0]; + return Reply::Stay; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if let Some(choice) = self.menu { + let Some(row) = self.picked(view) else { + self.menu = None; + return Reply::Stay; + }; + let acts = Self::acts(view, row, self.count.unwrap_or(1)); + let last = acts.len().saturating_sub(1); + let chosen = match key.code { + KeyCode::Down | KeyCode::Char('j') => { + self.menu = Some((choice + 1).min(last)); + None + } + KeyCode::Up | KeyCode::Char('k') => { + self.menu = Some(choice.saturating_sub(1)); + None + } + KeyCode::Enter | KeyCode::Char('l') => acts.get(choice), + KeyCode::Esc | KeyCode::Char('q' | 'h') => { + self.menu = None; + None + } + KeyCode::Char(c) => acts.iter().find(|a| a.key == c), + _ => None, + }; + return match chosen { + Some(act) => { + self.menu = None; + self.count = None; + Reply::Do(act.command) + } + None => Reply::Stay, + }; + } + if let KeyCode::Char(c @ '0'..='9') = key.code + && !ctrl + && (c != '0' || self.count.is_some()) + { + let digit = c.to_digit(10).unwrap_or(0); + self.count = Some((self.count.unwrap_or(0) * 10 + digit).min(9999)); + return Reply::Stay; + } + let count = self.count.take(); + let n = count.unwrap_or(1); + let twice = std::mem::take(&mut self.g); + let half = (self.page.get() / 2).max(1) as isize; + let side = self.side(view); + match key.code { + KeyCode::Char('d') if ctrl => self.move_by(view, half), + KeyCode::Char('u') if ctrl => self.move_by(view, -half), + KeyCode::Char('f') if ctrl => self.move_by(view, half * 2), + KeyCode::Char('b') if ctrl => self.move_by(view, -half * 2), + KeyCode::PageDown => self.move_by(view, half * 2), + KeyCode::PageUp => self.move_by(view, -half * 2), + KeyCode::Down | KeyCode::Char('j') => self.move_by(view, n as isize), + KeyCode::Up | KeyCode::Char('k') => self.move_by(view, -(n as isize)), + KeyCode::Home => self.cursor[usize::from(side)] = 0, + KeyCode::Char('g') if twice => self.cursor[usize::from(side)] = 0, + KeyCode::Char('g') => self.g = true, + KeyCode::End | KeyCode::Char('G') => { + let last = self.picks(view, side).len().saturating_sub(1); + let to = count.map_or(last, |n| (n as usize).saturating_sub(1).min(last)); + self.cursor[usize::from(side)] = to; + } + KeyCode::Left | KeyCode::Char('h') => self.on_beside = false, + KeyCode::Right | KeyCode::Char('l') => self.on_beside = view.beside.is_some(), + KeyCode::Tab | KeyCode::BackTab => self.on_beside = !side && view.beside.is_some(), + KeyCode::Char('/') => { + self.finding = true; + self.filter.clear(); + self.cursor = [0, 0]; + } + KeyCode::Esc if !self.filter.is_empty() => { + self.filter.clear(); + self.cursor = [0, 0]; + } + KeyCode::Esc | KeyCode::Char('q' | 'i') => return Reply::Close, + KeyCode::Char('o') => { + return self.sort(view, Sort { by: view.sort.by.next(), reversed: false }); + } + KeyCode::Char('O') => { + return self.sort(view, Sort { reversed: !view.sort.reversed, ..view.sort }); + } + KeyCode::Enter => { + if let Some(row) = self.picked(view) + && !Self::acts(view, row, n).is_empty() + { + self.menu = Some(0); + self.count = count; + } + } + KeyCode::Char(c) => { + if let Some(row) = self.picked(view) + && let Some(act) = Self::acts(view, row, n).into_iter().find(|a| a.key == c) + { + return Reply::Do(act.command); + } + // Wherever the cursor is, at an armourer's. + if c == 'R' && view.shop.is_some() { + return Reply::Do(Command::MendAll); + } + } + _ => {} + } + Reply::Stay + } + + /// Sorts the lists another way, keeping the cursor on what it was on. + fn sort(&mut self, view: &View, sort: Sort) -> Reply { + let sorted = View { sort, ..*view }; + for side in [false, true] { + let picks = self.picks(view, side); + let at = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1)); + if let Some(row) = picks.get(at) { + let now = self.picks(&sorted, side).iter().position(|r| r == row); + self.cursor[usize::from(side)] = now.unwrap_or(0); + } + } + Reply::Sort(sort) + } + + /// Draws the inventory over the whole of `area`. + pub fn render(&self, frame: &mut Frame, area: Rect, view: &View) { + frame.render_widget(Clear, area); + let [body, foot] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(area); + let wide = body.width >= WIDE; + // Below the lists, on a short screen the details give up room first. + let details_height = (body.height * 2 / 5).clamp(6, DETAILS_HEIGHT); + let (you, beside, details) = match view.beside { + Some(_) if wide => { + let [a, b, c] = Layout::horizontal([Constraint::Fill(1); 3]).areas(body); + (a, Some(b), c) + } + Some(_) => { + let [lists, details] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)]) + .areas(body); + let [a, b] = Layout::horizontal([Constraint::Fill(1); 2]).areas(lists); + (a, Some(b), details) + } + None if body.width >= 72 => { + let [a, c] = + Layout::horizontal([Constraint::Fill(3), Constraint::Fill(2)]).areas(body); + (a, None, c) + } + None => { + let [a, c] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)]) + .areas(body); + (a, None, c) + } + }; + self.draw_list(frame, you, view, false); + if let Some(beside) = beside { + self.draw_list(frame, beside, view, true); + } + self.draw_details(frame, details, view); + self.draw_foot(frame, foot, view.shop.is_some()); + } + + fn draw_list(&self, frame: &mut Frame, area: Rect, view: &View, beside: bool) { + let focused = self.side(view) == beside; + let title = if beside { + match view.beside { + Some((Beside::Lockers, stacks)) => format!( + " LOCKERS · {} / {} kg ", + items::kg(items::pile_mass(stacks)), + items::kg(LOCKER_CAPACITY) + ), + Some((Beside::Floor(here), _)) => format!(" {} ", here.to_uppercase()), + Some((Beside::Rack, _)) => { + format!(" FOR SALE · you have {} ¤ ", view.shop.as_ref().map_or(0, |s| s.scrip)) + } + None => String::new(), + } + } else { + let g = view.gear; + format!( + " YOU · {} / {} kg · {} ", + items::kg(g.carried()), + items::kg(g.capacity), + view.sort.says() + ) + }; + let colour = if focused { ACCENT } else { DIM }; + let block = Block::bordered() + .border_style(Style::new().fg(colour)) + .title(Line::from(title).fg(colour).bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = self.rows(view, beside); + let picked = self.picked(view); + let height = usize::from(inner.height); + if focused { + self.page.set(height); + } + let at = rows.iter().position(|r| Some(*r) == picked && focused).unwrap_or(0); + // Keep the picked row in view, with its heading if there's room. + let first = + at.saturating_sub(height.saturating_sub(1) / 2).min(rows.len().saturating_sub(height)); + let width = usize::from(inner.width); + // Which column is which, over the first heading in sight. + let mut labelled = false; + let mut lines: Vec<Line> = rows + .iter() + .skip(first) + .take(height) + .map(|&row| { + let line = match row { + Row::Heading(text) => { + heading(text, width, !std::mem::replace(&mut labelled, true)) + } + _ => self.row_line(view, row, width), + }; + if Some(row) == picked && focused { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + } + }) + .collect(); + if rows.is_empty() { + let rack = matches!(view.beside, Some((Beside::Rack, _))); + let empty = match (beside, self.filter.is_empty()) { + (_, false) => "Nothing like that.", + (true, true) if rack => "The rack's bare.", + (true, true) => "Nothing here.", + (false, true) => "Nothing on you.", + }; + lines.push(Line::from(format!(" {empty}")).fg(DIM)); + } + frame.render_widget(Paragraph::new(lines), inner); + } + + fn row_line(&self, view: &View, row: Row, width: usize) -> Line<'static> { + let weighed = width >= NARROW; + let label = match row { + Row::Heading(text) => return heading(text, width, true), + Row::You(Spot::Worn(slot)) => format!("{:<6}", slot.name()), + Row::You(Spot::Melee) => "melee ".into(), + Row::You(Spot::Gun) => "gun ".into(), + _ => String::new(), + }; + let Some(stack) = Self::stack(view, row) else { + return Line::from(vec![ + Span::raw(format!("{INDENT} {label}")).fg(DIM), + Span::raw("—").fg(DIM), + ]); + }; + let (glyph, colour) = look(&stack.item); + let mut name = stack.item.label(); + if stack.count > 1 { + name = format!("{name} ×{}", stack.count); + } + if let Item::Weapon(w) = &stack.item + && let Some(g) = w.gun + { + name = format!("{name} {}/{}", w.loaded, g.magazine); + } + if let Item::Armor(a) = &stack.item + && a.durability < a.max_durability + { + name = format!("{name} {}/{}", a.durability, a.max_durability); + } + let (price, price_colour) = match price(view, row, &stack) { + Price::Here(n, true) => (n.to_string(), ACCENT), + Price::Here(n, false) => (n.to_string(), DIM), + Price::About(n) => (format!("~{n}"), GUESS), + Price::Unknown => ("?".into(), DIM), + Price::Nothing => (String::new(), DIM), + }; + let kg = items::kg(stack.mass()); + let right = columns(width, &kg, &price); + let used = INDENT.len() + 2 + label.chars().count() + 1 + right.chars().count(); + let room = width.saturating_sub(used); + let shown: String = name.chars().take(room).collect(); + let pad = room.saturating_sub(shown.chars().count()); + let named = match &stack.item { + Item::Weapon(w) => w.named, + Item::Armor(a) => a.named, + _ => false, + }; + let name_style = Style::new().fg(rgb(colour)); + let mut spans = vec![ + Span::raw(INDENT), + Span::raw(glyph.to_string()).fg(rgb(colour)), + Span::raw(" "), + Span::raw(label).fg(DIM), + Span::styled(shown, if named { name_style.bold() } else { name_style }), + Span::raw(format!("{:pad$} ", "")), + ]; + if weighed { + spans.push(Span::raw(format!("{kg:>5} ")).fg(DIM)); + } + spans.push(Span::raw(format!("{price:>5} ")).fg(price_colour)); + Line::from(spans) + } + + fn draw_details(&self, frame: &mut Frame, area: Rect, view: &View) { + let picked = self.picked(view); + let stack = picked.and_then(|row| Self::stack(view, row)); + let title = stack.as_ref().map_or(String::new(), |s| format!(" {} ", s.item.label())); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(title).fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let Some(row) = picked else { return }; + let mut lines = about(view, row, stack.as_ref()); + let acts = Self::acts(view, row, self.count.unwrap_or(1)); + if !acts.is_empty() { + lines.push(Line::default()); + match self.menu { + Some(choice) => { + for (i, act) in acts.iter().enumerate() { + let line = Line::from(vec![ + Span::raw(format!(" {} ", act.key)).fg(ACCENT).bold(), + Span::raw(format!(" {} ", act.what)), + ]); + let chosen = i == choice.min(acts.len() - 1); + lines.push(if chosen { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + }); + } + } + None => { + let mut spans = Vec::new(); + for act in &acts { + spans.push(Span::raw(act.key.to_string()).fg(ACCENT).bold()); + spans.push(Span::raw(format!(" {} ", act.what)).fg(DIM)); + } + lines.push(Line::from(spans)); + } + } + } + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn draw_foot(&self, frame: &mut Frame, area: Rect, shop: bool) { + let keys: Vec<Span> = if self.finding { + let mut spans = vec![ + Span::raw(format!("/{}", self.filter)).fg(ACCENT), + Span::raw("█ ").fg(ACCENT), + ]; + spans.extend(keys::line(&[("enter", "keep"), ("esc", "clear")]).spans); + spans + } else if self.menu.is_some() { + keys::line(&[("j/k", "choose"), ("enter", "do it"), ("esc", "back")]).spans + } else { + let mut hints = vec![ + ("j/k", "move"), + ("gg/G", "top/end"), + ("^d/^u", "page"), + ("h/l", "lists"), + ("/", "find"), + ("o/O", "sort"), + ("enter", "act"), + ]; + if shop { + hints.push(("b/s", "buy/sell")); + hints.push(("r/R", "mend")); + } + hints.push(("esc", if shop { "step away" } else { "close" })); + keys::line(&hints).spans + }; + // What you're finding, a count you've typed and what just happened, + // above the keys. + let mut top = Vec::new(); + if !self.filter.is_empty() && !self.finding { + top.push(Span::raw(format!(" /{}", self.filter)).fg(ACCENT)); + } + if let Some(n) = self.count { + top.push(Span::raw(format!(" {n}…")).fg(ACCENT).bold()); + } + if let Some(note) = &self.note { + top.push(Span::raw(format!(" {note}")).fg(ACCENT)); + } + let lines = vec![Line::from(top), Line::from(keys)]; + frame.render_widget(Paragraph::new(lines), area); + } +} + +/// What's on the right of a row: what it weighs, in kg, and what it would +/// fetch; too narrow for both, only what it'd fetch. +fn columns(width: usize, kg: &str, price: &str) -> String { + if width >= NARROW { format!("{kg:>5} {price:>5} ") } else { format!("{price:>5} ") } +} + +/// A heading over a kind of thing: "WEAPONS", with which column is which +/// on the right if `units`. +fn heading(text: &str, width: usize, units: bool) -> Line<'static> { + let mut spans = vec![Span::raw(format!(" {text}")).fg(HEADING).bold()]; + if units { + let units = columns(width, "kg", "¤"); + let pad = width.saturating_sub(1 + text.chars().count() + units.chars().count()); + spans.push(Span::raw(format!("{:pad$}{units}", "")).fg(DIM)); + } + Line::from(spans) +} + +/// A description as a sentence: "Dense and faintly sweet." +fn sentence(text: &str) -> String { + capitalise(&format!("{}.", text.trim_end_matches('.'))) +} + +/// All about what's picked out. +fn about(view: &View, row: Row, stack: Option<&Stack>) -> Vec<Line<'static>> { + let dim = |text: String| Line::from(text).fg(DIM); + let Some(stack) = stack else { + return match row { + Row::You(Spot::Worn(slot)) => vec![dim(format!("Nothing on your {}.", slot.name()))], + _ => Vec::new(), + }; + }; + let mut lines = vec![Line::from(capitalise(&stack.name())).fg(rgb(look(&stack.item).1)).bold()]; + match &stack.item { + Item::Weapon(w) => weapon_lines(view, w, row, &mut lines), + Item::Armor(a) => armour_lines(view, a, row, &mut lines), + Item::Food(f) => { + lines.push(Line::from(sentence(&f.description)).italic().fg(DIM)); + lines.push(dim("Nothing gets hungry yet.".into())); + } + Item::Medpatch => { + lines.push(Line::from(format!( + "Stings, then numbs: heals {MEDPATCH_HEAL} and stops bleeding." + ))); + lines.push(dim("Aboard a wreck, m uses one (or a, here).".into())); + } + Item::Artifact(a) if a.identified => { + lines.push(Line::from(format!("A scholar has told you what it is: {}.", a.true_name))); + let value = trade::artifact_value(a); + lines.push(dim(format!("A scholar would give you about {value} ¤ for it."))); + } + Item::Artifact(_) => { + lines.push(Line::from("Something strange, from a wreck.")); + lines.push(dim("A scholar can tell you what it is, and buy it.".into())); + } + Item::Salvage(s) => { + lines.push(Line::from(format!("Worth about {} ¤ to a broker or a station.", s.value))); + } + } + if let Some(shop) = &view.shop { + match row { + Row::Beside(_) => { + if let Some(asking) = shop.terms.asking(&stack.item) { + let have = shop.scrip; + lines.push(if asking <= have { + Line::from(format!("{asking} ¤. You have {have} ¤.")) + } else { + Line::from(format!("{asking} ¤: {} ¤ more than you have.", asking - have)) + .yellow() + }); + } + } + _ => { + if let Some(offer) = shop.terms.offer(&stack.item) { + lines.push(Line::from(format!("They'd give you {offer} ¤ for it."))); + let alone = match row { + Row::You(Spot::Melee) => view.gear.spare(false).is_none(), + Row::You(Spot::Gun) => view.gear.spare(true).is_none(), + _ => false, + }; + if alone { + lines.push(dim("Have another of its sort to ready first.".into())); + } + } + } + } + } + if let Row::Aboard(_) = row { + lines.push(dim("In your ship's lockers.".into())); + } + let each = items::kg(stack.item.mass()); + let mut weighs = if stack.count > 1 { + format!("{each} kg each, {} kg in all.", items::kg(stack.mass())) + } else { + format!("{each} kg.") + }; + // Salvage and artifacts say what they're worth already. + if view.shop.is_none() + && matches!(stack.item, Item::Weapon(_) | Item::Armor(_)) + && let Some(offer) = trade::typical_offer(&stack.item) + { + weighs.push_str(&format!(" An armourer would give you about {offer} ¤ for it.")); + } + lines.push(dim(weighs)); + lines +} + +/// What a row's stack would fetch, or cost: at an armourer's, their price +/// for what they deal in; anywhere else, or for anything else, about what a +/// buyer would give. +fn price(view: &View, row: Row, stack: &Stack) -> Price { + if let Some(shop) = &view.shop { + let here = match row { + Row::Beside(_) => { + shop.terms.asking(&stack.item).map(|n| Price::Here(n, n <= shop.scrip)) + } + _ => shop.terms.offer(&stack.item).map(|n| Price::Here(n, true)), + }; + if let Some(here) = here { + return here; + } + } + match (&stack.item, trade::typical_offer(&stack.item)) { + (_, Some(n)) => Price::About(n.saturating_mul(stack.count)), + (Item::Artifact(_), None) => Price::Unknown, + _ => Price::Nothing, + } +} + +/// Which of two rows of a pile comes first, within their kind, by the +/// view's sort. Things the sort says nothing about (no price, no +/// durability) go last whichever way round; ties go by name. +fn order(view: &View, a: &(Row, &Stack), b: &(Row, &Stack)) -> std::cmp::Ordering { + let name = |(_, s): &(Row, &Stack)| s.item.label().to_lowercase(); + let by_name = name(a).cmp(&name(b)); + let sort = view.sort; + if sort.by == SortBy::Name { + return if sort.reversed { by_name.reverse() } else { by_name }; + } + let key = |&(row, s): &(Row, &Stack)| -> Option<f64> { + let grams = f64::from(s.mass()); + match sort.by { + SortBy::Name => None, + SortBy::Weight => Some(grams), + SortBy::Price => price(view, row, s).amount().map(f64::from), + SortBy::Density => { + let n = price(view, row, s).amount()?; + (grams > 0.0).then(|| f64::from(n) / grams) + } + // Most worn first: least left, as a share of whole. + SortBy::Durability => match &s.item { + Item::Armor(a) if a.max_durability > 0 => { + Some(-f64::from(a.durability) / f64::from(a.max_durability)) + } + _ => None, + }, + SortBy::Strength => match &s.item { + Item::Weapon(w) => { + let pellets = w.gun.map_or(1, |g| g.pellets); + Some(f64::from(w.damage.0 + w.damage.1) / 2.0 * f64::from(pellets)) + } + Item::Armor(a) => { + Some(f64::from(Dmg::ALL.iter().map(|&d| a.guard(d)).sum::<i32>())) + } + _ => None, + }, + SortBy::Newest => match row { + Row::You(Spot::Pack(i)) | Row::Beside(i) | Row::Aboard(i) => Some(i as f64), + _ => None, + }, + } + }; + let first = match (key(a), key(b)) { + (Some(x), Some(y)) => { + // Highest first, unless turned round. + let o = y.total_cmp(&x); + if sort.reversed { o.reverse() } else { o } + } + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + first.then(by_name) +} + +/// What the armourer you're at wants to mend `item`, if it's armour that +/// needs it. +fn mend_cost(view: &View, item: &Item) -> Option<u32> { + let shop = view.shop.as_ref()?; + let Item::Armor(a) = item else { return None }; + Some(shop.terms.mending(a)).filter(|&cost| cost > 0) +} + +/// How many pieces of yours want mending, on you and aboard, and what the +/// armourer you're at wants for all of them. +fn mend_all_cost(view: &View) -> Option<(usize, u32)> { + let shop = view.shop.as_ref()?; + let g = view.gear; + let packed = g.pack.iter().chain(shop.aboard).filter_map(|s| match &s.item { + Item::Armor(a) => Some(a), + _ => None, + }); + let costs: Vec<u32> = + g.worn.values().chain(packed).map(|a| shop.terms.mending(a)).filter(|&c| c > 0).collect(); + (!costs.is_empty()).then(|| (costs.len(), costs.iter().sum())) +} + +fn armour_lines(view: &View, a: &Armor, row: Row, lines: &mut Vec<Line<'static>>) { + let what = capitalise(&a.description); + let what = match a.condition { + Condition::Worn => Some(format!("{what}, worn: it won't last as long.")), + Condition::Sound if a.named => Some(format!("{what}.")), + Condition::Sound => None, + Condition::Fine => Some(format!("{what}, finely made.")), + Condition::Relic => Some(format!("{what}: a relic, and stronger for it.")), + }; + lines.extend(what.map(|what| Line::from(what).italic().fg(DIM))); + let on = match row { + Row::You(Spot::Worn(_)) => "You wear it", + _ => "Worn", + }; + lines.push(Line::from(format!("{on} on your {}. Guards {}.", a.slot.name(), a.guards_text()))); + let mut durability = capitalise(&a.durability_text()); + if a.ruined() { + durability.push_str(": worn out, it guards nothing until it's mended."); + } else if a.thin() { + durability.push_str(": below half, it guards 1 less."); + } else { + durability.push('.'); + } + let line = match mend_cost(view, &Item::Armor(a.clone())) { + Some(cost) if !matches!(row, Row::Beside(_)) => Line::from(vec![ + Span::raw(durability), + Span::raw(format!(" Mending it whole: {cost} ¤.")).fg(ACCENT), + ]), + _ => Line::from(durability), + }; + lines.push(match () { + _ if a.ruined() => line.red(), + _ if a.thin() => line.yellow(), + _ => line, + }); + if !matches!(row, Row::You(Spot::Worn(_))) { + lines.push(versus_armour(a, view.gear.worn.get(&a.slot))); + } + if view.shop.is_none() { + lines.push(dim_line("Each blow it softens wears it 1. An armourer mends it.")); + } +} + +fn dim_line(text: &str) -> Line<'static> { + Line::from(text.to_string()).fg(DIM) +} + +/// How `a` measures up against what you wear in its place, as it is now: +/// "Against your vac-helmet: +1 cut, -1 pierce." Better in green, worse in +/// yellow. +fn versus_armour(a: &Armor, worn: Option<&Armor>) -> Line<'static> { + let Some(worn) = worn else { + return dim_line(&format!("You wear nothing on your {} now.", a.slot.name())); + }; + let mut diffs: Vec<(i32, String)> = Vec::new(); + for dmg in Dmg::ALL { + let d = a.guard(dmg) - worn.guard(dmg); + if d != 0 { + diffs.push((d, format!("{d:+} {}", dmg.name()))); + } + } + match (a.stanching(), worn.stanching()) { + (true, false) => diffs.push((1, "stops bleeding".into())), + (false, true) => diffs.push((-1, "lets wounds bleed".into())), + _ => {} + } + let kg = |grams: u32| f64::from(grams) / 1000.0; + let heavier = kg(a.mass) - kg(worn.mass); + if heavier.abs() >= 0.1 { + diffs.push((if heavier < 0.0 { 1 } else { -1 }, format!("{heavier:+.1} kg"))); + } + let mut spans = vec![Span::raw(format!("Against {}: ", worn.yours())).fg(DIM)]; + if diffs.is_empty() { + spans.push(Span::raw("much the same.").fg(DIM)); + } + let last = diffs.len().saturating_sub(1); + for (i, (sign, text)) in diffs.into_iter().enumerate() { + let span = Span::raw(text); + spans.push(match sign { + s if s > 0 => span.green(), + s if s < 0 => span.yellow(), + _ => span, + }); + spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM)); + } + Line::from(spans) +} + +fn weapon_lines(view: &View, w: &Weapon, row: Row, lines: &mut Vec<Line<'static>>) { + let what = match w.condition { + _ if w.kit => { + "Your own. Lose everything else aboard a wreck and you keep this.".to_string() + } + Condition::Worn => format!("{}, worn: it hits softer.", capitalise(&w.description)), + Condition::Sound => format!("{}.", capitalise(&w.description)), + Condition::Fine => format!("{}, finely made.", capitalise(&w.description)), + Condition::Relic => { + format!("{}: a relic, and stronger for it.", capitalise(&w.description)) + } + }; + lines.push(Line::from(what).italic().fg(DIM)); + match row { + Row::You(Spot::Melee) => lines.push(Line::from("Ready: you hit things with it.")), + Row::You(Spot::Gun) => lines.push(Line::from("Ready: you fire it.")), + _ => {} + } + lines.extend(w.stats().into_iter().map(Line::from)); + if !matches!(row, Row::You(Spot::Melee | Spot::Gun)) { + let ready = if w.is_gun() { &view.gear.gun } else { &view.gear.melee }; + lines.push(versus(w, ready)); + } + lines.push(Line::from(w.dmg.about()).fg(DIM)); + let against = |pick: fn(Affinity) -> bool| { + let names: Vec<&str> = MONSTERS + .iter() + .filter(|k| view.known.contains(&(k.name, w.dmg)) && pick(k.affinity(w.dmg))) + .map(|k| k.name) + .collect(); + names.join(", ") + }; + let good = against(|a| a == Affinity::Weak); + if !good.is_empty() { + lines.push(Line::from(vec!["good against ".green(), Span::from(good)])); + } + let poor = against(|a| matches!(a, Affinity::Resists | Affinity::Immune)); + if !poor.is_empty() { + lines.push(Line::from(vec!["poor against ".yellow(), Span::from(poor)])); + } +} + +/// How `w` measures up against the weapon you have ready in its place: +/// "Against your needler: +3 damage, +5 range." Better in green, worse in +/// yellow. +fn versus(w: &Weapon, ready: &Weapon) -> Line<'static> { + let damage = |w: &Weapon| { + let (lo, hi) = w.damage; + f64::from(lo + hi) / 2.0 * f64::from(w.gun.map_or(1, |g| g.pellets)) + }; + let pct = |p: f64| (p * 100.0).round() as i32; + let mut diffs: Vec<(f64, String)> = Vec::new(); + let d = damage(w) - damage(ready); + if d.abs() >= 0.25 { + let d = (d * 2.0).round() / 2.0; + let shown = if d.fract() == 0.0 { format!("{d:+.0}") } else { format!("{d:+.1}") }; + diffs.push((d, format!("{shown} damage"))); + } + match (w.gun, ready.gun) { + (Some(g), Some(r)) => { + if g.range != r.range { + diffs + .push((f64::from(g.range - r.range), format!("{:+} range", g.range - r.range))); + } + if g.magazine != r.magazine { + let n = i64::from(g.magazine) - i64::from(r.magazine); + let rounds = if n.abs() == 1 { "round" } else { "rounds" }; + diffs.push((n as f64, format!("{n:+} {rounds}"))); + } + } + _ => { + let hit = pct(w.accuracy) - pct(ready.accuracy); + if hit != 0 { + diffs.push((f64::from(hit), format!("{hit:+}% to hit"))); + } + } + } + let crit = pct(w.crit) - pct(ready.crit); + if crit != 0 { + diffs.push((f64::from(crit), format!("{crit:+}% crit"))); + } + if w.dmg != ready.dmg { + diffs.push((0.0, format!("{} not {}", w.dmg.name(), ready.dmg.name()))); + } + let mut spans = vec![Span::raw(format!("Against {}: ", ready.yours())).fg(DIM)]; + if diffs.is_empty() { + spans.push(Span::raw("much the same.").fg(DIM)); + } + let last = diffs.len().saturating_sub(1); + for (i, (sign, text)) in diffs.into_iter().enumerate() { + let span = Span::raw(text); + spans.push(match sign { + s if s > 0.0 => span.green(), + s if s < 0.0 => span.yellow(), + _ => span, + }); + spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM)); + } + Line::from(spans) +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +/// Carries out a command that takes no time, outside a wreck: on what you +/// carry, the lockers if they're beside you, and the pile on the floor +/// where you stand. Says how it went. +pub fn carry_out( + command: Command, + gear: &mut Gear, + lockers: Option<&mut Vec<Stack>>, + floor: &mut Vec<Stack>, + beside: Option<Beside>, +) -> String { + let said = |result: Result<String, String>| result.unwrap_or_else(|e| e); + match command { + Command::Ready(i) => said(gear.ready(i)), + Command::Wear(i) => said(gear.wear(i)), + Command::TakeOff(slot) => said(gear.take_off(slot)), + Command::Eat(i) => said(gear.eat(i)), + Command::UseMedpatch => "You're not hurt.".into(), + Command::Drop(spot, n) => match gear.remove(spot, n) { + Ok(stack) => { + let said = format!("You put down {}.", stack.name()); + items::stow(floor, stack); + said + } + Err(said) => said, + }, + Command::Stow(spot, n) => { + let Some(lockers) = lockers else { return "There are no lockers here.".into() }; + let Some(stack) = gear.get(spot) else { return "There's nothing there.".into() }; + let n = n.min(stack.count); + let room = LOCKER_CAPACITY.saturating_sub(items::pile_mass(lockers)); + if stack.item.mass().saturating_mul(n) > room { + return "The lockers are full.".into(); + } + match gear.remove(spot, n) { + Ok(stack) => { + let said = format!("You stow {} in the lockers.", stack.name()); + items::stow(lockers, stack); + said + } + Err(said) => said, + } + } + Command::Fetch(i, n) => { + let (pile, from) = match (beside, lockers) { + (Some(Beside::Lockers), Some(lockers)) => (lockers, " from the lockers"), + _ => (floor, ""), + }; + let Some(stack) = pile.get(i) else { return "There's nothing there.".into() }; + let n = n.min(stack.count).min(gear.fits(&stack.item)); + if n == 0 { + let room = items::kg(gear.room()); + return format!("You've no room for {}: only {room} kg to spare.", stack.name()); + } + let stack = items::take(pile, i, n); + let said = format!("You take {}{from}.", stack.name()); + gear.add(stack); + said + } + Command::Buy(_) | Command::Sell(_) | Command::SellAboard(_) => { + "There's no one here to trade with.".into() + } + Command::Mend(_) | Command::MendAboard(_) | Command::MendAll => { + "There's no armourer here to mend it.".into() + } + } +} + +/// Takes the stacks lying at `at` out of a list of piles, to work on. +pub fn gather<K: PartialEq + Copy>(piles: &mut Vec<(K, Stack)>, at: K) -> Vec<Stack> { + let mut here = Vec::new(); + let mut i = 0; + while i < piles.len() { + if piles[i].0 == at { + here.push(piles.remove(i).1); + } else { + i += 1; + } + } + here +} + +/// Puts back what `gather` took, after. +pub fn scatter<K: Copy>(piles: &mut Vec<(K, Stack)>, at: K, here: Vec<Stack>) { + piles.extend(here.into_iter().map(|s| (at, s))); +} + +/// What's lying at `at` in a list of piles, for showing. +pub fn lying<K: PartialEq>(piles: &[(K, Stack)], at: K) -> Vec<Stack> { + piles.iter().filter(|(k, _)| *k == at).map(|(_, s)| s.clone()).collect() +} + +/// What to say when you step onto things: "There's a cleaver here (g)." +pub fn underfoot(here: &[Stack]) -> Option<String> { + match here { + [] => None, + [one] => Some(format!("There's {} here (g picks it up).", one.name())), + _ => Some(format!("There are {} things here (g to look).", here.len())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dungeon::{Pos, arms}; + use crate::names::Culture; + use crate::seed; + + fn press(inv: &mut Inventory, view: &View, code: KeyCode) -> Reply { + inv.key(KeyEvent::new(code, KeyModifiers::NONE), view) + } + + fn typed(inv: &mut Inventory, view: &View, keys: &str) -> Vec<Reply> { + keys.chars().map(|c| press(inv, view, KeyCode::Char(c))).collect() + } + + fn gear() -> Gear { + let mut gear = Gear::default(); + gear.add(Stack { item: Item::Medpatch, count: 5 }); + let mut rng = seed::rng(2); + gear.add(Stack::one(Item::Weapon(arms::find(&mut rng, arms::Source::Crew, false)))); + gear + } + + #[test] + fn vim_keys_move_and_counts_count() { + let gear = gear(); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: None, + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + // Worn ×4, ready ×2, then the pack: the torch is ready, so the pack + // has the found weapon and the medpatches. + assert_eq!(inv.picks(&view, false).len(), 8); + typed(&mut inv, &view, "G"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(0))), "medpatches come last"); + typed(&mut inv, &view, "gg"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Worn(Slot::Head)))); + typed(&mut inv, &view, "5j"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Gun))); + typed(&mut inv, &view, "G"); + let replies = typed(&mut inv, &view, "3d"); + assert_eq!(replies[1], Reply::Do(Command::Drop(Spot::Pack(0), 3))); + assert_eq!(typed(&mut inv, &view, "D")[0], Reply::Do(Command::Drop(Spot::Pack(0), 5))); + assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Stay, "no use for one here"); + assert_eq!(press(&mut inv, &view, KeyCode::Esc), Reply::Close); + } + + #[test] + fn at_an_armourers_you_buy_off_the_rack_and_sell_what_you_can_spare() { + // The torch and rivet gun ready, a found weapon and medpatches packed. + let gear = gear(); + let found = gear.pack.iter().position(|s| matches!(s.item, Item::Weapon(_))).unwrap(); + let mut rng = seed::rng(4); + let rack: Vec<Stack> = (0..3) + .map(|_| Stack::one(Item::Weapon(arms::stocked(&mut rng, Culture::Cant, false)))) + .collect(); + let aboard = [Stack::one(Item::Medpatch), rack[0].clone()]; + let terms = trade::armoury_terms(&crate::space::system::generate(1), 0); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: Some((Beside::Rack, &rack)), + known: &known, + hurt: false, + shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }), + sort: Sort::default(), + }; + let mut inv = Inventory::beside(); + let Some(Row::Beside(i)) = inv.picked(&view) else { panic!("starts on the rack") }; + assert_eq!(typed(&mut inv, &view, "b")[0], Reply::Do(Command::Buy(i))); + assert_eq!(typed(&mut inv, &view, "m")[0], Reply::Stay, "you can't just take it"); + press(&mut inv, &view, KeyCode::Char('h')); + // Only what they'd buy from your lockers, under its own heading. + let rows = inv.rows(&view, false); + let heading = rows.iter().position(|r| *r == Row::Heading("IN YOUR SHIP")).unwrap(); + assert_eq!(rows[heading + 1..], [Row::Aboard(1)]); + typed(&mut inv, &view, "G"); + assert_eq!(typed(&mut inv, &view, "s")[0], Reply::Do(Command::SellAboard(1))); + let acts = |row| Inventory::acts(&view, row, 1); + let sells = |row| acts(row).iter().any(|a| a.key == 's'); + assert!(sells(Row::You(Spot::Pack(found)))); + let Item::Weapon(w) = &gear.pack[found].item else { unreachable!() }; + let (spared, alone) = + if w.is_gun() { (Spot::Gun, Spot::Melee) } else { (Spot::Melee, Spot::Gun) }; + assert!(sells(Row::You(spared)), "ready, with a spare to take its place"); + assert!(!sells(Row::You(alone)), "not with nothing else to fight with"); + assert!(!sells(Row::You(Spot::Pack(1 - found))), "they don't buy medpatches"); + // What they'd pay here, beside what it weighs. + let item = &gear.pack[found].item; + let price = terms.offer(item).unwrap(); + let line = inv.row_line(&view, Row::You(Spot::Pack(found)), 40).to_string(); + let kg = items::kg(item.mass()); + assert!(line.ends_with(&format!("{kg:>5} {price:>5} ")), "{line}"); + assert!(line.starts_with(" ) "), "indented under its heading: {line}"); + } + + #[test] + fn rows_show_what_things_weigh_and_about_what_theyd_fetch() { + let mut gear = gear(); + let mut rng = seed::rng(3); + let scrap = items::Salvage { title: "hull plate".into(), value: 20, mass: 12_000 }; + gear.add(Stack { item: Item::Salvage(scrap), count: 3 }); + gear.add(Stack::one(Item::Artifact(crate::names::artifact(&mut rng)))); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: None, + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let inv = Inventory::new(); + let line = |row| inv.row_line(&view, row, 50).to_string(); + let at = |pick: fn(&Item) -> bool| { + Row::You(Spot::Pack(gear.pack.iter().position(|s| pick(&s.item)).unwrap())) + }; + // A whole stack's worth, and its weight. + assert!(line(at(|i| matches!(i, Item::Salvage(_)))).ends_with(" 36.0 ~60 ")); + assert!(line(at(|i| matches!(i, Item::Artifact(_)))).ends_with(" ? "), "not named yet"); + assert!(line(at(|i| *i == Item::Medpatch)).ends_with(" "), "no one buys them"); + let weapon = at(|i| matches!(i, Item::Weapon(_))); + let Row::You(Spot::Pack(w)) = weapon else { unreachable!() }; + let about = trade::typical_offer(&gear.pack[w].item).unwrap(); + assert!(line(weapon).ends_with(&format!("~{about} "))); + // The headings say which column is which. + let heading = inv.row_line(&view, Row::Heading("WEAPONS"), 50).to_string(); + assert!(heading.starts_with(" WEAPONS") && heading.ends_with(" kg ¤ "), "{heading}"); + // Too narrow for both, just the price. + assert!(inv.row_line(&view, weapon, 24).to_string().ends_with(&format!("~{about} "))); + assert!(!inv.row_line(&view, weapon, 24).to_string().contains(" kg")); + } + + #[test] + fn o_sorts_both_lists_within_each_kind_and_o_turns_it_round() { + let mut gear = Gear::default(); + let scrap = |title: &str, value, mass| { + Stack::one(Item::Salvage(items::Salvage { title: title.into(), value, mass })) + }; + gear.add(scrap("brass fitting", 30, 1000)); + gear.add(scrap("hull plate", 20, 12_000)); + gear.add(scrap("coil", 50, 4000)); + gear.add(Stack { item: Item::Medpatch, count: 2 }); + let floor = [scrap("valve", 5, 500), scrap("anchor", 90, 30_000)]; + let known = BTreeSet::new(); + let mut view = View { + gear: &gear, + beside: Some((Beside::Floor("on the floor"), &floor)), + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + let salvage = |inv: &Inventory, view: &View, beside: bool| -> Vec<String> { + inv.rows(view, beside) + .into_iter() + .filter_map(|r| Inventory::stack(view, r)) + .filter(|s| matches!(s.item, Item::Salvage(_))) + .map(|s| s.item.label()) + .collect() + }; + assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]); + // On the coil, then sorted heaviest first: the cursor stays on it. + inv.cursor[0] = + inv.picks(&view, false).iter().position(|r| *r == Row::You(Spot::Pack(2))).unwrap(); + let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('o')) else { panic!() }; + assert_eq!(sort, Sort { by: SortBy::Weight, reversed: false }); + view.sort = sort; + assert_eq!(salvage(&inv, &view, false), ["hull plate", "coil", "brass fitting"]); + assert_eq!(salvage(&inv, &view, true), ["anchor", "valve"], "the floor too"); + assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(2)))); + let rows = inv.rows(&view, false); + let medical = rows.iter().position(|r| *r == Row::Heading("MEDICAL")).unwrap(); + let salvage_at = rows.iter().position(|r| *r == Row::Heading("SALVAGE")).unwrap(); + assert!(medical < salvage_at, "still grouped by kind"); + + let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('O')) else { panic!() }; + view.sort = sort; + assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]); + assert_eq!(sort.says(), "lightest first"); + + view.sort = Sort { by: SortBy::Density, reversed: false }; + assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]); + view.sort = Sort { by: SortBy::Newest, reversed: false }; + assert_eq!(salvage(&inv, &view, false), ["coil", "hull plate", "brass fitting"]); + // Round they go, back to names. + let mut by = SortBy::Name; + for _ in 0..SortBy::ALL.len() { + by = by.next(); + } + assert_eq!(by, SortBy::Name); + } + + #[test] + fn worn_armour_sorts_first_by_durability_and_the_rest_after() { + let mut rng = seed::rng(9); + let mut gear = Gear::default(); + for left in [10, 2, 6] { + let mut a = crate::dungeon::armour::stocked(&mut rng, Culture::Cant, false); + (a.title, a.max_durability, a.durability) = (format!("piece {left}"), 10, left); + gear.add(Stack::one(Item::Armor(a))); + } + let known = BTreeSet::new(); + let mut view = View { + gear: &gear, + beside: None, + known: &known, + hurt: false, + shop: None, + sort: Sort { by: SortBy::Durability, reversed: false }, + }; + let inv = Inventory::new(); + let armour = |view: &View| -> Vec<String> { + inv.rows(view, false) + .into_iter() + .filter_map(|r| match r { + Row::You(Spot::Pack(_)) => Inventory::stack(view, r), + _ => None, + }) + .map(|s| s.item.label()) + .collect() + }; + assert_eq!(armour(&view), ["piece 2", "piece 6", "piece 10"]); + view.sort.reversed = true; + assert_eq!(armour(&view), ["piece 10", "piece 6", "piece 2"]); + } + + #[test] + fn at_an_armourers_worn_armour_can_be_mended() { + let mut rng = seed::rng(6); + let mut piece = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false); + let mut gear = Gear::default(); + piece.durability = 1; + gear.worn.insert(piece.slot, piece.clone()); + let mut spare = piece.clone(); + spare.slot = piece.slot; + gear.add(Stack::one(Item::Armor(spare))); + let whole = { + let mut p = piece.clone(); + p.repair(); + p + }; + let aboard = [Stack::one(Item::Armor(whole))]; + let terms = trade::armoury_terms(&crate::space::system::generate(1), 0); + let known = BTreeSet::new(); + let rack = Vec::new(); + let view = View { + gear: &gear, + beside: Some((Beside::Rack, &rack)), + known: &known, + hurt: false, + shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }), + sort: Sort::default(), + }; + let cost = terms.mending(&piece); + let acts = Inventory::acts(&view, Row::You(Spot::Worn(piece.slot)), 1); + let mend = acts.iter().find(|a| a.key == 'r').expect("an act to mend it"); + assert_eq!(mend.what, format!("mend it for {cost} ¤")); + let all = acts.iter().find(|a| a.key == 'R').expect("and to mend them all"); + assert_eq!(all.what, format!("mend all 2 for {} ¤", cost * 2)); + let whole_acts = Inventory::acts(&view, Row::Aboard(0), 1); + assert!(!whole_acts.iter().any(|a| a.key == 'r'), "nothing to mend in a whole one"); + // R mends everything from wherever the cursor is. + let mut inv = Inventory::beside(); + assert_eq!(typed(&mut inv, &view, "R")[0], Reply::Do(Command::MendAll)); + // The list shows how worn it is. + let line = inv.row_line(&view, Row::You(Spot::Worn(piece.slot)), 60).to_string(); + assert!(line.contains(&format!(" 1/{}", piece.max_durability)), "{line}"); + } + + #[test] + fn armour_says_how_it_measures_up_to_what_you_wear() { + let mut rng = seed::rng(6); + let mut worn = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false); + worn.title = "vac-helmet".into(); + worn.guards = [(Dmg::Pierce, 1), (Dmg::Blunt, 1)].into_iter().collect(); + worn.mass = 1800; + let mut other = worn.clone(); + other.guards = [(Dmg::Cut, 1), (Dmg::Blunt, 1)].into_iter().collect(); + other.mass = 1300; + assert_eq!( + versus_armour(&other, Some(&worn)).to_string(), + "Against your vac-helmet: +1 cut, -1 pierce, -0.5 kg." + ); + assert!(versus_armour(&other, None).to_string().starts_with("You wear nothing on your")); + } + + #[test] + fn a_weapon_says_how_it_measures_up_to_the_one_you_have_ready() { + let torch = arms::cutting_torch(); + let mut better = torch.clone(); + better.damage = (4, 7); + better.accuracy = 0.75; + assert_eq!( + versus(&better, &torch).to_string(), + "Against your cutting torch: +2 damage, -5% to hit." + ); + assert_eq!( + versus(&torch, &torch).to_string(), + "Against your cutting torch: much the same." + ); + let gun = arms::rivet_gun(); + let mut longer = gun.clone(); + longer.gun.as_mut().unwrap().range += 4; + longer.dmg = Dmg::Shock; + assert_eq!( + versus(&longer, &gun).to_string(), + "Against your rivet gun: +4 range, shock not pierce." + ); + } + + #[test] + fn finding_narrows_the_list() { + let gear = gear(); + let known = BTreeSet::new(); + let view = View { + gear: &gear, + beside: None, + known: &known, + hurt: true, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + press(&mut inv, &view, KeyCode::Char('/')); + typed(&mut inv, &view, "medp"); + press(&mut inv, &view, KeyCode::Enter); + assert_eq!(inv.picks(&view, false), [Row::You(Spot::Pack(0))]); + assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Do(Command::UseMedpatch)); + press(&mut inv, &view, KeyCode::Esc); + assert_eq!(inv.picks(&view, false).len(), 8, "esc clears it"); + } + + #[test] + fn the_menu_lists_what_you_can_do() { + let gear = gear(); + let known = BTreeSet::new(); + let lockers = vec![Stack { item: Item::Medpatch, count: 2 }]; + let view = View { + gear: &gear, + beside: Some((Beside::Lockers, &lockers)), + known: &known, + hurt: false, + shop: None, + sort: Sort::default(), + }; + let mut inv = Inventory::new(); + typed(&mut inv, &view, "G"); + press(&mut inv, &view, KeyCode::Enter); + assert_eq!(inv.menu, Some(0)); + typed(&mut inv, &view, "jj"); + // drop one, drop them all, stow one, stow them all. + assert_eq!( + press(&mut inv, &view, KeyCode::Enter), + Reply::Do(Command::Stow(Spot::Pack(0), 1)) + ); + typed(&mut inv, &view, "l"); + assert_eq!(typed(&mut inv, &view, "M")[0], Reply::Do(Command::Fetch(0, 2))); + } + + #[test] + fn carrying_out_moves_things_about() { + let mut gear = gear(); + let mut lockers = Vec::new(); + let mut floor = Vec::new(); + let lockers_beside = Some(Beside::Lockers); + let said = carry_out( + Command::Stow(Spot::Pack(0), 2), + &mut gear, + Some(&mut lockers), + &mut floor, + lockers_beside, + ); + assert_eq!(said, "You stow 2 medpatches in the lockers."); + assert_eq!((gear.medpatches(), lockers[0].count), (3, 2)); + carry_out(Command::Fetch(0, 9), &mut gear, Some(&mut lockers), &mut floor, lockers_beside); + assert_eq!((gear.medpatches(), lockers.len()), (5, 0)); + // Your own torch can go, and the blade in your pack takes its place; + // but not the blade too, with nothing left to hit things with. + let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None); + assert_eq!(said, "You put down your cutting torch."); + assert!(!gear.melee.kit); + let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None); + assert!(said.contains("nothing to hit things with"), "{said}"); + assert_eq!(floor.len(), 1); + let said = carry_out( + Command::Fetch(0, 1), + &mut gear, + None, + &mut floor, + Some(Beside::Floor("on the deck")), + ); + assert!(said.starts_with("You take"), "{said}"); + assert!(floor.is_empty()); + } + + #[test] + fn piles_gather_and_scatter() { + let mut piles = vec![ + (Pos::new(1, 1), Stack::one(Item::Medpatch)), + (Pos::new(2, 2), Stack::one(Item::Medpatch)), + (Pos::new(1, 1), Stack { item: Item::Medpatch, count: 3 }), + ]; + let here = gather(&mut piles, Pos::new(1, 1)); + assert_eq!((here.len(), piles.len()), (2, 1)); + scatter(&mut piles, Pos::new(1, 1), here); + assert_eq!(lying(&piles, Pos::new(1, 1)).len(), 2); + assert!(underfoot(&lying(&piles, Pos::new(2, 2))).unwrap().contains("a medpatch")); + } +} diff --git a/src/scenes/keys.rs b/src/scenes/keys.rs new file mode 100644 index 0000000..8c07fed --- /dev/null +++ b/src/scenes/keys.rs @@ -0,0 +1,97 @@ +//! Keys every screen shares: which way the movement keys go, and how key +//! hints are drawn. A hint is the key in the accent colour, bold, then what +//! it does, dimmed; hints sit two spaces apart, and side panels pack as +//! many to a line as fit. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::style::{Color, Stylize}; +use ratatui::text::{Line, Span}; + +pub const ACCENT: Color = Color::Rgb(240, 200, 110); +pub const DIM: Color = Color::Rgb(120, 124, 142); + +/// Vi keys, arrows, and the number pad with or without num lock. +pub fn direction(code: KeyCode) -> Option<(i32, i32)> { + Some(match code { + KeyCode::Left | KeyCode::Char('h' | '4') => (-1, 0), + KeyCode::Right | KeyCode::Char('l' | '6') => (1, 0), + KeyCode::Up | KeyCode::Char('k' | '8') => (0, -1), + KeyCode::Down | KeyCode::Char('j' | '2') => (0, 1), + KeyCode::Home | KeyCode::Char('y' | '7') => (-1, -1), + KeyCode::PageUp | KeyCode::Char('u' | '9') => (1, -1), + KeyCode::End | KeyCode::Char('b' | '1') => (-1, 1), + KeyCode::PageDown | KeyCode::Char('n' | '3') => (1, 1), + _ => return None, + }) +} + +/// A step, or with shift a run: which way, and whether to run. Shifted vi +/// keys come as capitals. +pub fn movement(key: KeyEvent) -> Option<(i32, i32, bool)> { + let (code, run) = match key.code { + KeyCode::Char(c) if c.is_ascii_uppercase() => (KeyCode::Char(c.to_ascii_lowercase()), true), + code => (code, key.modifiers.contains(KeyModifiers::SHIFT)), + }; + direction(code).map(|(dx, dy)| (dx, dy, run)) +} + +/// One hint: "esc back". +fn hint(key: &str, what: &str) -> [Span<'static>; 2] { + [Span::raw(key.to_string()).fg(ACCENT).bold(), Span::raw(format!(" {what}")).fg(DIM)] +} + +/// Hints in a row. +pub fn line(hints: &[(&str, &str)]) -> Line<'static> { + let mut spans = Vec::new(); + for (i, &(key, what)) in hints.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.extend(hint(key, what)); + } + Line::from(spans) +} + +/// Hints packed into lines at most `width` wide, as many to a line as fit. +/// A hint is never split; one too wide for any line gets a line to itself. +pub fn packed(hints: &[(&str, &str)], width: u16) -> Vec<Line<'static>> { + let width = usize::from(width); + let mut lines: Vec<Line<'static>> = Vec::new(); + for &(key, what) in hints { + let [k, w] = hint(key, what); + let needs = k.width() + w.width(); + match lines.last_mut() { + Some(last) if last.width() + 2 + needs <= width => { + last.spans.extend([Span::raw(" "), k, w]); + } + _ => lines.push(Line::from(vec![k, w])), + } + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(line: &Line) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn hints_pack_without_splitting() { + let hints = [("f", "fire"), ("r", "reload"), ("esc", "menu: save and quit")]; + let lines: Vec<String> = packed(&hints, 16).iter().map(text).collect(); + assert_eq!(lines, ["f fire r reload", "esc menu: save and quit"]); + assert_eq!(text(&line(&hints[..2])), "f fire r reload"); + } + + #[test] + fn shift_or_a_capital_runs() { + let key = |code, modifiers| movement(KeyEvent::new(code, modifiers)); + assert_eq!(key(KeyCode::Char('h'), KeyModifiers::NONE), Some((-1, 0, false))); + assert_eq!(key(KeyCode::Char('L'), KeyModifiers::SHIFT), Some((1, 0, true))); + assert_eq!(key(KeyCode::Down, KeyModifiers::SHIFT), Some((0, 1, true))); + assert_eq!(key(KeyCode::Char('G'), KeyModifiers::SHIFT), None); + } +} diff --git a/src/scenes/mod.rs b/src/scenes/mod.rs index 60db647..1c20edf 100644 --- a/src/scenes/mod.rs +++ b/src/scenes/mod.rs @@ -1,9 +1,25 @@ -//! Scenes: the title screen, the name forge, and derelicts to board; -//! flight and ports later. +//! Scenes: the title screen, characters and their saves, the name forge, +//! derelicts to board, flight (around a place, across the system map, and +//! between systems on the star map), and what's on the ground: cities to +//! walk, and ports. +pub mod aboard; +pub mod character; pub mod derelict; +pub mod flight; pub mod forge; +pub mod galaxy_map; +pub mod inventory; +pub mod keys; +pub mod pause; +pub mod port; +pub mod saves; +pub mod sky; +pub mod system_map; pub mod title; +pub mod town; +#[cfg(test)] +mod zz_dump; use ratatui::Frame; use ratatui::crossterm::event::KeyEvent; @@ -15,8 +31,14 @@ pub enum Transition { None, /// Open a scene on top of this one. Popping it comes back here. Push(Box<dyn Scene>), + /// Open several scenes at once, each on top of the one before. + Open(Vec<Box<dyn Scene>>), /// Close this scene and return to the one beneath it. Pop, + /// Close this scene and open these in its place. + Replace(Vec<Box<dyn Scene>>), + /// Close every scene but the title screen, at the bottom of the stack. + Title, Quit, } @@ -33,10 +55,15 @@ pub trait Scene { } fn render(&self, frame: &mut Frame, ctx: &Ctx); + + /// This scene has come to the top of the stack: just opened, or + /// uncovered when the ones above it closed. What it shows may need + /// bringing up to date with what happened up there. + fn on_top(&mut self, _ctx: &Ctx) {} } #[cfg(test)] -mod tests { +pub(crate) mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::crossterm::event::{KeyCode, KeyModifiers}; @@ -53,13 +80,130 @@ mod tests { #[test] fn scenes_render_at_any_size() { let mut ctx = test_ctx(); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let mut pausing = derelict::Derelict::new(2); + pausing.key(esc, &mut ctx); let mut abandoning = derelict::Derelict::new(2); - abandoning.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut ctx); - let scenes: [Box<dyn Scene>; 4] = [ - Box::new(title::Title::new(&ctx)), + abandoning.key(esc, &mut ctx); + abandoning.key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), &mut ctx); + let mut aiming = derelict::Derelict::new(2); + aiming.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), &mut ctx); + aiming.key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), &mut ctx); + let mut packing = derelict::Derelict::new(2); + packing.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), &mut ctx); + // Something of every kind, on you and in the lockers. + { + use crate::dungeon::armour; + use crate::dungeon::arms::Source; + use crate::items::{Food, Item, Salvage, Slot, Stack}; + let mut rng = crate::seed::rng(8); + let mut armor = |slot| { + let mut a = armour::find(&mut rng, Source::Armoury, false); + a.slot = slot; + a.durability = 1; + a + }; + let food = Food { + title: "ration brick".into(), + description: "dense and grey".into(), + nourishment: 10, + mass: 400, + }; + let scrap = Salvage { title: "coil of superconductor".into(), value: 30, mass: 6000 }; + let g = &mut ctx.hold.gear; + g.worn.insert(Slot::Body, armor(Slot::Body)); + g.add(Stack::one(Item::Armor(armor(Slot::Head)))); + g.add(Stack { item: Item::Food(food), count: 3 }); + g.add(Stack { item: Item::Medpatch, count: 2 }); + ctx.hold.lockers.push(Stack { item: Item::Salvage(scrap.clone()), count: 4 }); + ctx.hold.deck.push((crate::dungeon::Pos::new(0, 0), Stack::one(Item::Salvage(scrap)))); + } + let mut rummaging = derelict::Derelict::board(2, &mut ctx); + for code in [KeyCode::Char('i'), KeyCode::Char('j'), KeyCode::Enter] { + rummaging.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx); + } + let home = ctx.system.home_place(); + let mut stowing = aboard::Aboard::landed(&ctx, home); + for code in [KeyCode::Char('i'), KeyCode::Char('l'), KeyCode::Char('/')] { + stowing.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx); + } + let mut strolling = town::TownScene::new(&ctx, home); + strolling.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), &mut ctx); + let mut leaving = town::TownScene::new(&ctx, home); + leaving.key(esc, &mut ctx); + let mut resting = aboard::Aboard::landed(&ctx, home); + resting.key(esc, &mut ctx); + let mut flying = flight::FlightScene::new(&ctx); + flying.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx); + let mut holding = flight::FlightScene::new(&ctx); + holding.key(esc, &mut ctx); + let mut docked = port::Port::new(&ctx, 0); + docked.key(esc, &mut ctx); + let belt = ctx.system.places.len() - 1; + let mut trading = town::TownScene::new(&ctx, home); + for _ in 0..12 { + trading.key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut ctx); + } + let wreck = ctx.system.wrecks_on(ctx.day).pop(); + let mut renaming = character::Sheet::new(); + renaming.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), &mut ctx); + let mut creating = character::Create::new(&ctx); + creating.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut ctx); + let dir = crate::save::scratch("render"); + ctx.saves = Some(dir.clone()); + ctx.slot = Some(crate::save::new_slot(&dir, &ctx.character, ctx.seed)); + ctx.record.slain.insert("rust-mite".into(), 2); + ctx.bestiary.insert(("rust-mite", crate::dungeon::arms::Dmg::Blunt)); + ctx.autosave(); + std::fs::write(dir.join("broken.json"), "{").unwrap(); + let playing = title::Title::new(&ctx); + let mut plotting = galaxy_map::GalaxyMap::new(&ctx); + plotting.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx); + let mut far = galaxy_map::GalaxyMap::new(&ctx); + for _ in 0..6 { + far.key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut ctx); + } + let mut stranded = test_ctx(); + let dead = (0..stranded.galaxy.sites.len()) + .find(|&i| !stranded.galaxy.sites[i].kind.sells_fuel()) + .unwrap(); + stranded.enter_system(dead); + stranded.hold.fuel = 0; + stranded.hold.debt = 120; + let scenes: [Box<dyn Scene>; 33] = [ + Box::new(title::Title::new(&test_ctx())), + Box::new(playing), + Box::new(character::Create::new(&ctx)), + Box::new(creating), + Box::new(character::Sheet::new()), + Box::new(renaming), + Box::new(saves::Saves::new(&ctx)), Box::new(forge::Forge::new(&ctx)), Box::new(derelict::Derelict::new(1)), + Box::new(pausing), Box::new(abandoning), + Box::new(holding), + Box::new(docked), + Box::new(leaving), + Box::new(resting), + Box::new(aiming), + Box::new(packing), + Box::new(rummaging), + Box::new(stowing), + Box::new(strolling), + Box::new(flying), + Box::new(system_map::SystemMap::new(&ctx)), + Box::new(galaxy_map::GalaxyMap::new(&ctx)), + Box::new(plotting), + Box::new(far), + Box::new(port::Port::new(&ctx, ctx.system.home_place())), + Box::new(port::Port::new(&ctx, belt)), + Box::new(port::Port::new(&ctx, 0)), + Box::new(town::TownScene::new(&ctx, home)), + Box::new(trading), + Box::new(aboard::Aboard::landed(&ctx, home)), + Box::new(aboard::Aboard::resumed(&ctx, home)), + Box::new(aboard::Aboard::adrift("in orbit around nowhere", wreck)), ]; for (width, height) in [(160, 50), (80, 24), (40, 12), (5, 3), (1, 1)] { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); @@ -69,6 +213,9 @@ mod tests { terminal.draw(|frame| scene.render(frame, &ctx)).unwrap(); } } + let map = galaxy_map::GalaxyMap::new(&stranded); + terminal.draw(|frame| map.render(frame, &stranded)).unwrap(); } + let _ = std::fs::remove_dir_all(dir); } } diff --git a/src/scenes/pause.rs b/src/scenes/pause.rs new file mode 100644 index 0000000..41cff43 --- /dev/null +++ b/src/scenes/pause.rs @@ -0,0 +1,160 @@ +//! The menu `esc` opens over a game screen: keep playing, do what `esc` +//! used to do there (abandon ship, back to your ship), or save and quit to +//! the title. It opens on keep playing, and `esc` again closes it: quitting +//! is always something you choose. +//! +//! Like the inventory, it's a panel a scene holds and draws over itself, +//! not a scene of its own: the scene beneath stays in view, and stops while +//! the menu's open. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; + +use super::keys::{self, ACCENT, DIM}; + +/// What you chose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + /// Save, and go back to the title screen. + Quit, + /// Whatever `esc` does here besides: abandon ship, leave the building. + Here, + /// Close the menu and carry on. + Resume, +} + +pub struct Pause { + choice: usize, + /// What else you can do here, if there's anything: "Abandon ship". + here: Option<&'static str>, + /// A word about where you are, or which way to go. + note: Option<String>, +} + +impl Pause { + pub fn new(here: Option<&'static str>, note: Option<String>) -> Pause { + Pause { choice: 0, here, note } + } + + fn choices(&self) -> Vec<(Choice, &'static str)> { + let mut choices = vec![(Choice::Resume, "Keep playing")]; + if let Some(here) = self.here { + choices.push((Choice::Here, here)); + } + choices.push((Choice::Quit, "Save and quit to the title")); + choices + } + + /// Takes a key. Says what you chose, if you chose anything. A key held + /// down doesn't count again: holding `esc` mustn't flicker the menu. + pub fn key(&mut self, key: KeyEvent) -> Option<Choice> { + if key.kind == KeyEventKind::Repeat { + return None; + } + let choices = self.choices(); + let last = choices.len() - 1; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => Some(Choice::Resume), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => { + self.choice = (self.choice + 1).min(last); + None + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => { + self.choice = self.choice.saturating_sub(1); + None + } + KeyCode::Char('g') | KeyCode::Home => { + self.choice = 0; + None + } + KeyCode::Char('G') | KeyCode::End => { + self.choice = last; + None + } + KeyCode::Enter | KeyCode::Char(' ' | 'l') => Some(choices[self.choice.min(last)].0), + KeyCode::Char(c @ '1'..='9') => { + choices.get(c as usize - '1' as usize).map(|&(choice, _)| choice) + } + _ => None, + } + } + + /// Draws the menu in the middle of `area`. + pub fn render(&self, frame: &mut Frame, area: Rect) { + let mut lines = Vec::new(); + if let Some(note) = &self.note { + lines.push(Line::from(note.clone()).fg(DIM)); + lines.push(Line::default()); + } + for (i, (_, what)) in self.choices().into_iter().enumerate() { + let chosen = i == self.choice; + let marker = if chosen { "› " } else { " " }; + let line = Line::from(vec![ + Span::raw(format!("{} ", i + 1)).fg(ACCENT), + Span::raw(marker).fg(ACCENT), + Span::raw(what), + ]); + lines.push(if chosen { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + }); + } + lines.push(Line::default()); + lines.push(keys::line(&[("↑↓ enter", "choose"), ("esc", "keep playing")])); + + let [popup] = Layout::horizontal([Constraint::Max(48)]).flex(Flex::Center).areas(area); + let width = usize::from(popup.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 + 2).unwrap_or(u16::MAX); + let [popup] = + Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup); + let block = Block::bordered() + .border_style(Style::new().fg(ACCENT)) + .title(Line::from(" PAUSED ").fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + frame.render_widget(Clear, popup); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), popup); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + + fn press(pause: &mut Pause, code: KeyCode) -> Option<Choice> { + pause.key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + fn esc_again_closes_the_menu_and_holding_it_does_nothing() { + let mut pause = Pause::new(Some("Abandon ship"), None); + let held = KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Repeat); + assert_eq!(pause.key(held), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume), "wherever the cursor is"); + } + + #[test] + fn you_can_pick_what_else_esc_does_or_carry_on() { + let mut pause = Pause::new(Some("Abandon ship"), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Resume), "it opens on that"); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Here)); + assert_eq!(press(&mut pause, KeyCode::Char('G')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('1')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('3')), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('q')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('x')), None, "anything else does nothing"); + let mut pause = Pause::new(None, None); + assert_eq!(press(&mut pause, KeyCode::Char('2')), Some(Choice::Quit)); + } +} 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); + } +} diff --git a/src/scenes/saves.rs b/src/scenes/saves.rs new file mode 100644 index 0000000..ca78eb2 --- /dev/null +++ b/src/scenes/saves.rs @@ -0,0 +1,278 @@ +//! The saved characters: pick one up where you left them, or delete them. + +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}; + +use super::flight; +use super::forge::colour; +use super::keys::{self, ACCENT, DIM}; +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::save::{self, Slot}; + +/// Lines each save takes in the list, the gap after it included. +const ROWS_EACH: u16 = 3; + +pub struct Saves { + slots: Vec<Slot>, + selected: usize, + /// Asking whether to delete the selected save. + deleting: bool, + problem: Option<String>, +} + +impl Saves { + pub fn new(ctx: &Ctx) -> Saves { + let slots = ctx.saves.as_deref().map(save::list).unwrap_or_default(); + // Whoever you're playing is first, being the most recently saved. + Saves { slots, selected: 0, deleting: false, problem: None } + } + + fn playing(&self, ctx: &Ctx, i: usize) -> bool { + ctx.slot.as_ref() == Some(&self.slots[i].path) + } + + fn load(&mut self, ctx: &mut Ctx) -> Transition { + let Some(slot) = self.slots.get(self.selected) else { return Transition::None }; + if let Err(why) = &slot.summary { + self.problem = Some(format!("That save can't be read: {why}")); + return Transition::None; + } + if !self.playing(ctx, self.selected) { + ctx.autosave(); + if let Err(why) = save::load(&slot.path, ctx) { + self.problem = Some(format!("That save can't be read: {why}")); + return Transition::None; + } + } + Transition::Replace(flight::resume(ctx)) + } + + fn delete(&mut self, ctx: &mut Ctx) { + let path = self.slots[self.selected].path.clone(); + if let Err(why) = save::delete(&path) { + self.problem = Some(format!("Couldn't delete it: {why}")); + return; + } + // Delete who you're playing and there's no one left to save. + if ctx.slot.as_ref() == Some(&path) { + ctx.slot = None; + } + self.slots.remove(self.selected); + self.selected = self.selected.min(self.slots.len().saturating_sub(1)); + } +} + +/// "just now", "5 minutes ago", "3 days ago". +fn ago(seconds: u64) -> String { + let (n, unit) = match seconds { + 0..60 => return "just now".into(), + 60..3600 => (seconds / 60, "minute"), + 3600..86_400 => (seconds / 3600, "hour"), + _ => (seconds / 86_400, "day"), + }; + match n { + 1 if unit == "hour" => "an hour ago".into(), + 1 => format!("a {unit} ago"), + _ => format!("{n} {unit}s ago"), + } +} + +impl Scene for Saves { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + self.problem = None; + if self.deleting { + self.deleting = false; + if matches!(key.code, KeyCode::Char('y' | 'Y')) { + self.delete(ctx); + } + return Transition::None; + } + let last = self.slots.len().saturating_sub(1); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => return Transition::Pop, + KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => self.selected = (self.selected + 1).min(last), + KeyCode::Enter => return self.load(ctx), + KeyCode::Char('d') | KeyCode::Delete if !self.slots.is_empty() => self.deleting = true, + _ => {} + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [area] = + Layout::horizontal([Constraint::Length(84)]).flex(Flex::Center).areas(frame.area()); + let block = Block::bordered() + .border_style(Style::new().fg(DIM)) + .title(Line::from(" SAVED CHARACTERS ").fg(ACCENT).bold()) + .padding(Padding::uniform(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let [list, _, footer] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1), Constraint::Length(2)]) + .areas(inner); + + let mut lines = Vec::new(); + if self.slots.is_empty() { + lines.push( + Line::from("No one's saved yet. Make a new character from the title.").fg(DIM), + ); + } + // Enough of the list to keep the selected save in view. + let shown = usize::from((list.height / ROWS_EACH).max(1)); + let first = (self.selected + 1).saturating_sub(shown); + let now = save::now(); + for (i, slot) in self.slots.iter().enumerate().skip(first).take(shown) { + let chosen = i == self.selected; + let marker = if chosen { Span::raw("› ").fg(ACCENT).bold() } else { Span::raw(" ") }; + match &slot.summary { + Ok(s) => { + let c = &s.character; + let mut who = vec![ + marker, + Span::raw(c.name.clone()).fg(colour(c.origin)).bold(), + Span::raw(format!(", {}, of {}", c.calling(), c.ship_name())), + ]; + if self.playing(ctx, i) { + who.push(Span::raw(" playing").fg(ACCENT)); + } + lines.push(Line::from(who)); + let boarded = match s.record.boarded { + 1 => "1 wreck boarded".to_string(), + n => format!("{n} wrecks boarded"), + }; + lines.push( + Line::from(format!( + " day {:.1} at {} · {} ¤ · {boarded} · saved {}", + s.day, + s.place, + s.scrip, + ago(now.saturating_sub(s.saved)) + )) + .fg(DIM), + ); + } + Err(why) => { + let file = slot.path.file_name().unwrap_or_default().to_string_lossy(); + lines.push(Line::from(vec![marker, Span::raw(file.into_owned()).fg(DIM)])); + lines.push(Line::from(format!(" can't be read: {why}")).fg(Color::LightRed)); + } + } + lines.push(Line::default()); + } + frame.render_widget(Paragraph::new(lines), list); + + let name = |i: usize| match &self.slots[i].summary { + Ok(s) => s.character.name.clone(), + Err(_) => "this save".into(), + }; + let mut help = Vec::new(); + if self.deleting { + let who = name(self.selected); + help.push(Line::from(format!("Delete {who} for good?")).fg(Color::LightRed)); + help.push(keys::line(&[("y", "delete"), ("any other key", "keep them")])); + } else { + if let Some(problem) = &self.problem { + help.push(Line::from(problem.as_str()).fg(Color::LightRed)); + } else if let Some(dir) = &ctx.saves { + help.push(Line::from(format!("saved in {}", dir.display())).fg(DIM)); + } + help.push(keys::line(&[ + ("↑↓", "choose"), + ("enter", "play"), + ("d", "delete"), + ("esc", "back"), + ])); + } + frame.render_widget(Paragraph::new(help), footer); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::names::Culture; + use crate::save::Character; + use crate::term::KeyboardCaps; + + fn press(scene: &mut Saves, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + /// A saves folder with two characters in it, and the game playing the + /// second. + fn two(dir: &std::path::Path) -> Ctx { + let mut ctx = Ctx::new(1, KeyboardCaps { key_release: false }); + ctx.saves = Some(dir.to_path_buf()); + for (name, universe, scrip) in [("Ada Vell", 11, 300), ("Bram Oskel", 22, 5)] { + let character = + Character { name: name.into(), origin: Culture::Cant, ship: "Bolt".into() }; + ctx.begin(universe, character); + ctx.slot = Some(save::new_slot(dir, &ctx.character, universe)); + ctx.hold.scrip = scrip; + ctx.autosave(); + assert!(ctx.save_error.is_none()); + } + ctx + } + + #[test] + fn loading_picks_up_where_they_left_off() { + let dir = save::scratch("saves-load"); + let mut ctx = two(&dir); + let mut saves = Saves::new(&ctx); + assert_eq!(saves.slots.len(), 2); + // Both were saved within the same second, so either may come first. + let ada = saves + .slots + .iter() + .position(|s| s.summary.as_ref().unwrap().character.name == "Ada Vell") + .unwrap(); + ctx.hold.scrip = 6; + saves.selected = ada; + assert!(matches!(press(&mut saves, &mut ctx, KeyCode::Enter), Transition::Replace(_))); + assert_eq!((ctx.character.name.as_str(), ctx.seed, ctx.hold.scrip), ("Ada Vell", 11, 300)); + + // Bram was saved as they were left, scrip and all. + let bram = save::new_slot( + &dir, + &Character { name: "Bram Oskel".into(), origin: Culture::Cant, ship: "Bolt".into() }, + 22, + ); + save::load(&bram, &mut ctx).unwrap(); + assert_eq!(ctx.hold.scrip, 6); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn deleting_asks_first() { + let dir = save::scratch("saves-delete"); + let mut ctx = two(&dir); + let mut saves = Saves::new(&ctx); + press(&mut saves, &mut ctx, KeyCode::Char('d')); + press(&mut saves, &mut ctx, KeyCode::Char('n')); + assert_eq!(saves.slots.len(), 2, "anything but yes keeps it"); + let playing = saves.playing(&ctx, 0); + press(&mut saves, &mut ctx, KeyCode::Char('d')); + press(&mut saves, &mut ctx, KeyCode::Char('y')); + assert_eq!((saves.slots.len(), save::list(&dir).len()), (1, 1)); + assert_eq!(ctx.slot.is_none(), playing, "delete who you're playing and nothing's saved"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn saved_times_read_naturally() { + assert_eq!(ago(5), "just now"); + assert_eq!(ago(60), "a minute ago"); + assert_eq!(ago(3 * 3600 + 5), "3 hours ago"); + assert_eq!(ago(3600), "an hour ago"); + assert_eq!(ago(86_400 * 2), "2 days ago"); + } +} diff --git a/src/scenes/sky.rs b/src/scenes/sky.rs new file mode 100644 index 0000000..8248dd0 --- /dev/null +++ b/src/scenes/sky.rs @@ -0,0 +1,460 @@ +//! Drawing space: starfields, lit planets, rocks, stations, wrecks and your +//! ship, in terminal cells. +//! +//! A local map unit is one cell across and half a cell down (cells are +//! about twice as tall as wide), so a circle of radius r spans 2r columns +//! and r rows, and looks round. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier}; + +use crate::names::Culture; +use crate::seed; +use crate::space::Vec2; +use crate::space::flight::Ship; +use crate::space::system::Rgb; + +pub fn rgb((r, g, b): Rgb) -> Color { + Color::Rgb(r, g, b) +} + +/// `colour` at `k` times its brightness (k from 0 to about 1.3). +pub fn shade((r, g, b): Rgb, k: f64) -> Color { + let f = |c: u8| (f64::from(c) * k).round().clamp(0.0, 255.0) as u8; + Color::Rgb(f(r), f(g), f(b)) +} + +/// Between `a` (at k = 0) and `b` (at k = 1). +pub fn mix(a: Rgb, b: Rgb, k: f64) -> Rgb { + let k = k.clamp(0.0, 1.0); + let f = |x: u8, y: u8| (f64::from(x) * (1.0 - k) + f64::from(y) * k).round() as u8; + (f(a.0, b.0), f(a.1, b.1), f(a.2, b.2)) +} + +/// A number from 0 to 1 that's always the same for the same inputs. +pub fn hash01(seed: u64, x: i64, y: i64) -> f64 { + let h = seed::derive(seed, "", (x as u64) << 32 ^ (y as u64 & 0xffff_ffff)); + (h >> 11) as f64 / (1u64 << 53) as f64 +} + +/// Where on screen a map position lands, with the camera in the middle of +/// `area`. Fractional: the cell is the floor of each coordinate. +#[derive(Clone, Copy)] +pub struct View { + pub area: Rect, + pub camera: Vec2, +} + +impl View { + pub fn screen(&self, p: Vec2) -> (f64, f64) { + let a = self.area; + let col = f64::from(a.x) + f64::from(a.width) / 2.0 + (p.x - self.camera.x); + let row = f64::from(a.y) + f64::from(a.height) / 2.0 + (p.y - self.camera.y) / 2.0; + (col, row) + } + + /// The cell a map position falls in, if it's on screen. + pub fn cell(&self, p: Vec2) -> Option<(u16, u16)> { + let (col, row) = self.screen(p); + cell_in(self.area, col.floor(), row.floor()) + } + + pub fn put(&self, buf: &mut Buffer, p: Vec2, ch: char, fg: Color) { + if let Some(pos) = self.cell(p) { + buf[pos].set_char(ch).set_fg(fg); + } + } +} + +fn cell_in(area: Rect, col: f64, row: f64) -> Option<(u16, u16)> { + let inside = col >= f64::from(area.x) + && row >= f64::from(area.y) + && col < f64::from(area.right()) + && row < f64::from(area.bottom()); + inside.then_some((col as u16, row as u16)) +} + +/// Writes `text` centred on a screen column, clipped to `area`. +pub fn label(buf: &mut Buffer, area: Rect, col: f64, row: f64, text: &str, fg: Color) { + let width = text.chars().count() as f64; + let start = (col - width / 2.0).floor(); + for (i, ch) in text.chars().enumerate() { + if let Some(pos) = cell_in(area, start + i as f64, row.floor()) { + buf[pos].set_char(ch).set_fg(fg); + } + } +} + +/// Star layers: how fast each moves as the camera does, how rare its stars +/// are, glyph, and colour. Slower layers read as further away. +const STAR_LAYERS: [(f64, u32, char, Rgb); 3] = [ + (0.04, 90, '.', (70, 72, 92)), + (0.12, 170, '·', (130, 132, 150)), + (0.3, 420, '+', (210, 210, 225)), +]; + +pub fn starfield(buf: &mut Buffer, area: Rect, camera: Vec2, seed: u64) { + for (layer, &(parallax, rarity, glyph, colour)) in STAR_LAYERS.iter().enumerate() { + let layer_seed = seed::derive(seed, "stars", layer as u64); + let dx = (camera.x * parallax).floor() as i64; + let dy = (camera.y * parallax / 2.0).floor() as i64; + for row in area.top()..area.bottom() { + for col in area.left()..area.right() { + let h = hash01(layer_seed, i64::from(col) + dx, i64::from(row) + dy); + if h * f64::from(rarity) < 1.0 { + buf[(col, row)].set_char(glyph).set_fg(rgb(colour)); + } + } + } + } +} + +/// How to paint a sphere. +pub struct Surface { + pub rgb: Rgb, + /// Cloud bands, for giants. + pub banded: bool, + /// City lights on the night side. + pub lights: bool, + /// Glyphs from darkest to brightest. + pub ramp: &'static [char], + /// Varies the mottling and bands from one body to the next. + pub seed: u64, +} + +pub const PLANET_RAMP: &[char] = &['·', '░', '▒', '▓', '█']; +pub const ROCK_RAMP: &[char] = &['.', ':', '%', '#', '@']; + +/// A sphere centred at screen position (`col`, `row`), `radius` columns +/// across, lit from `sun` (a unit vector on the map, y down). Returns +/// whether it covered any cell. +pub fn sphere( + buf: &mut Buffer, + area: Rect, + (col, row): (f64, f64), + radius: f64, + sun: Vec2, + surface: &Surface, +) -> bool { + // Mostly from the side, a little from in front, so there's always a + // terminator to see. + let light = { + let (x, y, z) = (sun.x, sun.y, 0.45); + let len = (x * x + y * y + z * z).sqrt(); + (x / len, y / len, z / len) + }; + let top = (row - radius / 2.0).floor() as i64; + let bottom = (row + radius / 2.0).ceil() as i64; + let left = (col - radius).floor() as i64; + let right = (col + radius).ceil() as i64; + let mut drawn = false; + for r in top..=bottom { + for c in left..=right { + let nx = (c as f64 + 0.5 - col) / radius; + let ny = (r as f64 + 0.5 - row) * 2.0 / radius; + let d2 = nx * nx + ny * ny; + if d2 > 1.0 { + continue; + } + let Some(pos) = cell_in(area, c as f64, r as f64) else { + continue; + }; + drawn = true; + let nz = (1.0 - d2).sqrt(); + let lit = (nx * light.0 + ny * light.1 + nz * light.2).max(0.0); + let grain = |n: f64| { + let g = hash01(surface.seed, (nx * n).floor() as i64, (ny * n).floor() as i64); + 0.86 + 0.14 * g + }; + let mut tone = grain(5.0); + if surface.banded { + let phase = (surface.seed % 628) as f64 / 100.0; + tone = + 0.84 + 0.12 * (ny * 7.0 + phase).sin() + 0.06 * (ny * 19.0 + phase * 2.0).sin(); + } + let cell = &mut buf[pos]; + if lit < 0.04 { + let city = surface.lights && grain(13.0) > 0.99 - 0.08; + if city { + cell.set_char('·').set_fg(Color::Rgb(255, 196, 110)); + } else { + cell.set_char(surface.ramp[0]).set_fg(shade(surface.rgb, 0.16 * tone)); + } + continue; + } + let steps = surface.ramp.len() - 1; + let glyph = surface.ramp[1 + ((lit * steps as f64) as usize).min(steps - 1)]; + cell.set_char(glyph).set_fg(shade(surface.rgb, (0.2 + 0.95 * lit) * tone)); + } + } + drawn +} + +/// One glyph per eighth of a turn, for headings (0 is up, clockwise). +pub fn arrow(heading: f64) -> char { + const ARROWS: [char; 8] = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖']; + let eighth = (heading / std::f64::consts::TAU * 8.0).round() as i64; + ARROWS[eighth.rem_euclid(8) as usize] +} + +/// Each culture's station, drawn centred on its position. +pub fn station_art(culture: Culture) -> (&'static [&'static str], Rgb) { + match culture { + Culture::Qaari => (&[" .-=-. ", "=(( o ))=", " '-=-' "], (232, 190, 112)), + Culture::Cant => (&[" /\\ ", "=[|##|]=", " |##| ", " \\/ "], (204, 124, 92)), + Culture::Houses => (&[" _/^^\\_ ", " <|-<>-|>", " \\__/ "], (206, 186, 240)), + Culture::Drift => (&[" =o= =o= ", "<==[ ]==>", " =o= =o= "], (124, 212, 200)), + } +} + +pub fn draw_station(buf: &mut Buffer, view: &View, culture: Culture, ticks: u64) { + let (art, colour) = station_art(culture); + let (col, row) = view.screen(Vec2::ZERO); + let top = row - art.len() as f64 / 2.0; + for (i, line) in art.iter().enumerate() { + let width = line.chars().count() as f64; + let start = (col - width / 2.0).floor(); + for (j, ch) in line.chars().enumerate() { + if ch == ' ' { + continue; + } + let Some(pos) = cell_in(view.area, start + j as f64, (top + i as f64).floor()) else { + continue; + }; + // Running lights blink in turn. + let light = ch == 'o' && (ticks / 15 + j as u64).is_multiple_of(3); + let fg = if light { Color::Rgb(255, 240, 200) } else { rgb(colour) }; + buf[pos].set_char(ch).set_fg(fg); + if light { + buf[pos].modifier.insert(Modifier::BOLD); + } + } + } +} + +/// A wreck: a dark hull pointing along its heading, with a red distress +/// beacon blinking if `beacon` (there's still anything aboard, and the +/// beacon hasn't failed). +pub fn draw_wreck( + buf: &mut Buffer, + view: &View, + pos: Vec2, + heading: f64, + beacon: bool, + ticks: u64, +) { + let along = Vec2::from_heading(heading); + let hull = Color::Rgb(112, 118, 140); + view.put(buf, pos - along * 1.5, '=', hull); + view.put(buf, pos, '#', hull); + view.put(buf, pos + along * 1.5, arrow(heading), hull); + if beacon && ticks % 40 < 8 { + view.put(buf, pos, '#', Color::Rgb(255, 70, 60)); + } +} + +/// Your ship's outline, a notched dart: corners as (ahead, to the right) +/// of its position, in map units. About five and a half units long. +const HULL: [(f64, f64); 4] = [(2.75, 0.0), (-2.75, 2.1), (-0.75, 0.0), (-2.75, -2.1)]; + +/// Where the engine flame starts: the notch at the back of the hull. +const NOZZLE: f64 = -0.75; + +/// Braille dot bits by (column, row) within a cell. +const BRAILLE: [[u32; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]; + +/// Your ship, turned to exactly its heading, with a flame out the back +/// while the engine is lit. Drawn in braille dots, which are half a unit +/// square (two across a cell, four down), so it turns smoothly rather than +/// snapping between eight arrows. +pub fn draw_ship(buf: &mut Buffer, view: &View, ship: &Ship, ticks: u64) { + let ahead = Vec2::from_heading(ship.heading); + let right = Vec2::new(-ahead.y, ahead.x); + let local = |p: Vec2| (p.dot(ahead), p.dot(right)); + let (col, row) = view.screen(ship.pos); + if ship.thrusting { + // A plume, widest just behind the nozzle, flickering between short + // and long ten times a second. Its first unit or two is mostly + // hidden under the hull, which reads better than filling the notch. + let length = 4.0 + 2.0 * hash01(0, (ticks / 3) as i64, 0); + let flame = [ + (NOZZLE, 0.5), + (NOZZLE - 1.5, 1.0), + (NOZZLE - length, 0.0), + (NOZZLE - 1.5, -1.0), + (NOZZLE, -0.5), + ]; + for ((c, r), ch) in braille(view, ship.pos, 7.0, |p| inside(&flame, local(p))) { + // Yellow near the ship, cooling to red-orange at the tip. + let middle = Vec2::new(f64::from(c) + 0.5 - col, (f64::from(r) + 0.5 - row) * 2.0); + let k = (NOZZLE - local(middle).0) / length; + let colour = mix((255, 224, 130), (240, 80, 30), (k - 0.3) / 0.7); + buf[(c, r)].set_char(ch).set_fg(rgb(colour)); + } + } + // The hull goes on top: where the two share a cell, the hull wins. + for (cell, ch) in braille(view, ship.pos, 3.5, |p| inside(&HULL, local(p))) { + buf[cell].set_char(ch).set_fg(Color::White); + } +} + +/// The braille characters for a shape within `reach` map units of `pos`, +/// and the cells they go in. `covers` says whether a point (in map units +/// from `pos`) is inside the shape, and a dot is lit when the shape covers +/// at least 40% of it, which keeps edges clean at every angle. +fn braille( + view: &View, + pos: Vec2, + reach: f64, + covers: impl Fn(Vec2) -> bool, +) -> Vec<((u16, u16), char)> { + // Samples per dot, each way. + const SAMPLES: u32 = 4; + let sub = |i: u32| (f64::from(i) + 0.5) / f64::from(SAMPLES); + let (col, row) = view.screen(pos); + let mut cells = Vec::new(); + for r in (row - reach / 2.0).floor() as i64..=(row + reach / 2.0).floor() as i64 { + for c in (col - reach).floor() as i64..=(col + reach).floor() as i64 { + let Some(cell) = cell_in(view.area, c as f64, r as f64) else { + continue; + }; + let mut bits = 0; + for (dx, column) in BRAILLE.iter().enumerate() { + for (dy, bit) in column.iter().enumerate() { + let mut hits = 0; + for sx in 0..SAMPLES { + for sy in 0..SAMPLES { + // In map units from `pos`: a column is one, a row two. + let x = c as f64 - col + (dx as f64 + sub(sx)) / 2.0; + let y = (r as f64 - row + (dy as f64 + sub(sy)) / 4.0) * 2.0; + hits += u32::from(covers(Vec2::new(x, y))); + } + } + if hits * 5 >= SAMPLES * SAMPLES * 2 { + bits |= bit; + } + } + } + if let Some(ch) = char::from_u32(0x2800 + bits).filter(|_| bits != 0) { + cells.push((cell, ch)); + } + } + } + cells +} + +/// Whether `point` is inside `polygon` (even-odd rule, so it may be concave). +fn inside(polygon: &[(f64, f64)], (x, y): (f64, f64)) -> bool { + let mut odd = false; + for (i, &(x1, y1)) in polygon.iter().enumerate() { + let (x2, y2) = polygon[(i + 1) % polygon.len()]; + if (y1 > y) != (y2 > y) && x < x1 + (y - y1) * (x2 - x1) / (y2 - y1) { + odd = !odd; + } + } + odd +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every lit braille dot in `buf`, in map units from the centre of + /// `view`, as (ahead, to the right) for a ship facing `heading`. + fn ship_dots(buf: &Buffer, view: &View, heading: f64) -> Vec<(f64, f64)> { + let ahead = Vec2::from_heading(heading); + let right = Vec2::new(-ahead.y, ahead.x); + let (col, row) = view.screen(view.camera); + let mut dots = Vec::new(); + for r in 0..view.area.height { + for c in 0..view.area.width { + let ch = buf[(c, r)].symbol().chars().next().unwrap_or(' '); + let bits = u32::from(ch).wrapping_sub(0x2800); + for (dx, column) in BRAILLE.iter().enumerate() { + for (dy, &bit) in column.iter().enumerate() { + if bits <= 0xff && bits & bit != 0 { + let x = f64::from(c) - col + (dx as f64 + 0.5) / 2.0; + let y = (f64::from(r) - row + (dy as f64 + 0.5) / 4.0) * 2.0; + let p = Vec2::new(x, y); + dots.push((p.dot(ahead), p.dot(right))); + } + } + } + } + } + dots + } + + fn ship(heading: f64, thrusting: bool) -> Ship { + Ship { pos: Vec2::ZERO, vel: Vec2::ZERO, heading, thrusting } + } + + #[test] + fn the_ship_points_its_nose_along_its_heading() { + let view = View { area: Rect::new(0, 0, 20, 10), camera: Vec2::ZERO }; + for heading in [0.0, 1.0, 1.9, 2.5, 3.6, 4.4, 5.5] { + let mut buf = Buffer::empty(view.area); + draw_ship(&mut buf, &view, &ship(heading, false), 0); + let dots = ship_dots(&buf, &view, heading); + assert!((24..=36).contains(&dots.len()), "{heading}: {} dots", dots.len()); + let front = dots.iter().filter(|d| d.0 > 1.25); + assert!(front.clone().count() >= 2, "{heading}: a nose"); + assert!(front.clone().all(|d| d.1.abs() < 1.25), "{heading}: a narrow nose"); + let wing = |side: f64| dots.iter().any(|d| d.0 < -1.5 && d.1 * side > 1.25); + assert!(wing(1.0) && wing(-1.0), "{heading}: wings behind, either side"); + } + } + + #[test] + fn a_lit_engine_trails_a_flame_out_the_back() { + let view = View { area: Rect::new(0, 0, 24, 12), camera: Vec2::ZERO }; + for heading in [0.0, 2.0, 4.0] { + // The flame's dots, as how far ahead of the ship each one is. + let flame = |thrusting: bool, ticks: u64| { + let mut buf = Buffer::empty(view.area); + draw_ship(&mut buf, &view, &ship(heading, thrusting), ticks); + for cell in buf.content.iter_mut().filter(|c| c.fg == Color::White) { + cell.reset(); + } + ship_dots(&buf, &view, heading) + }; + assert!(flame(false, 0).is_empty(), "{heading}: no flame with the engine off"); + let lit = flame(true, 0); + assert!(!lit.is_empty(), "{heading}: a flame with it on"); + assert!(lit.iter().all(|d| d.0 < -1.0), "{heading}: only behind: {lit:?}"); + let sizes: Vec<usize> = (0..60).step_by(3).map(|t| flame(true, t).len()).collect(); + assert!(sizes.iter().min() < sizes.iter().max(), "{heading}: flickering"); + } + } + + #[test] + fn arrows_point_where_headings_do() { + use std::f64::consts::TAU; + assert_eq!(arrow(0.0), '↑'); + assert_eq!(arrow(TAU / 4.0), '→'); + assert_eq!(arrow(TAU - 0.1), '↑'); + assert_eq!(arrow(-TAU / 8.0), '↖'); + } + + #[test] + fn spheres_are_round_and_lit_on_the_sunward_side() { + let area = Rect::new(0, 0, 40, 20); + let mut buf = Buffer::empty(area); + let surface = Surface { + rgb: (200, 200, 200), + banded: false, + lights: false, + ramp: PLANET_RAMP, + seed: 1, + }; + assert!(sphere(&mut buf, area, (20.0, 10.0), 8.0, Vec2::new(1.0, 0.0), &surface)); + let row: String = (0..40).map(|c| buf[(c, 10)].symbol().chars().next().unwrap()).collect(); + let covered = row.chars().filter(|&c| c != ' ').count(); + assert!((15..=17).contains(&covered), "8 units across each way: {row:?}"); + let column: usize = (0..20).filter(|&r| buf[(20, r)].symbol() != " ").count(); + assert!((7..=9).contains(&column), "and half that many rows tall"); + assert_eq!(buf[(26, 10)].symbol(), "█", "bright to the east, where the sun is"); + assert_eq!(buf[(13, 10)].symbol(), "·", "dark to the west"); + } +} diff --git a/src/scenes/system_map.rs b/src/scenes/system_map.rs new file mode 100644 index 0000000..65c6011 --- /dev/null +++ b/src/scenes/system_map.rs @@ -0,0 +1,830 @@ +//! The system map: the star, every orbit, where everything is today, and a +//! list of places to travel to with how long each trip takes. +//! +//! Distances from the star are drawn on a square-root scale, so the inner +//! planets aren't crushed into the middle and the outer ones still fit. +//! Moons are too close to their planets to see at any scale that shows the +//! whole system, so they're drawn just off their planets, in the right +//! direction. +//! +//! Choosing a place shows where it will be when you get there: trips aim +//! for that, not for where it is now, and a survey of what it's like. +//! +//! Every place is marked with what you'll find if you land or dock there: +//! a capital city (⌂), a station that only trades (¤), or neither (·). +//! +//! `g` swaps this for the star map, to jump to another system. + +use std::f64::consts::TAU; + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::symbols::Marker; +use ratatui::text::{Line, Span}; +use ratatui::widgets::canvas::{Canvas, Context, Points}; +use ratatui::widgets::{Block, Paragraph}; + +use super::galaxy_map::GalaxyMap; +use super::keys; +use super::sky; +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::names::Culture; +use crate::space::Vec2; +use crate::space::orbit; +use crate::space::survey::{self, FREEZING, Survey, Temperature}; +use crate::space::system::{Landing, PlaceKind, Rgb, System, Wreck}; +use crate::town; + +const PANEL_WIDTH: u16 = 46; +/// Rows for what's known about the chosen place. +const ABOUT_HEIGHT: u16 = 20; +/// The labels down the left of the survey: "temp", "air". +const LABEL_WIDTH: usize = 7; +const ACCENT: Color = Color::Rgb(240, 200, 110); +const DIM: Color = Color::Rgb(110, 114, 132); +const ORBIT: Color = Color::Rgb(58, 62, 80); +const CAPITAL: Color = Color::Rgb(130, 214, 140); +const MARKET: Color = Color::Rgb(110, 196, 226); +const WRECK: Color = Color::Rgb(255, 90, 80); +/// A beacon that's failing, or will have failed by the time you get there. +pub(crate) const FAINT_WRECK: Color = Color::Rgb(150, 76, 70); +/// A beacon with this few days left is failing. +pub(crate) const FAILING: f64 = 5.0; +const SUIT_UP: Color = Color::Rgb(240, 150, 90); + +pub struct SystemMap { + selected: usize, +} + +impl SystemMap { + pub fn new(ctx: &Ctx) -> SystemMap { + SystemMap { selected: ctx.location } + } + + /// Days to get from where you are to `place`. + fn trip(ctx: &Ctx, place: usize) -> f64 { + let s = &ctx.system; + let from = s.position(ctx.location, ctx.day); + orbit::plan_trip(from, ctx.day, |d| s.position(place, d)) + } +} + +impl Scene for SystemMap { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + let n = ctx.system.places.len(); + match key.code { + KeyCode::Up | KeyCode::Char('k' | 'w') => self.selected = (self.selected + n - 1) % n, + KeyCode::Down | KeyCode::Char('j' | 's') => self.selected = (self.selected + 1) % n, + KeyCode::Enter | KeyCode::Char('t') => { + if self.selected != ctx.location { + let days = Self::trip(ctx, self.selected); + ctx.travel(self.selected, days); + } + return Transition::Pop; + } + KeyCode::Char('.') => ctx.day += 1.0, + KeyCode::Char('g') => return Transition::Replace(vec![Box::new(GalaxyMap::new(ctx))]), + KeyCode::Esc | KeyCode::Char('m' | 'q') => return Transition::Pop, + _ => {} + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let area = frame.area(); + let (map, panel) = if area.width >= 100 { + let [map, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(area); + (Some(map), panel) + } else { + (None, area) + }; + if let Some(map) = map { + self.draw_orrery(frame, map, ctx); + } + self.draw_panel(frame, panel, ctx); + } +} + +/// Positions in AU to canvas points, on a square-root scale out to `extent`. +struct Scale { + radius: f64, + extent: f64, +} + +impl Scale { + fn point(&self, p: Vec2) -> (f64, f64) { + let d = p.length(); + if d == 0.0 { + return (0.0, 0.0); + } + let r = self.radius * (d / self.extent).sqrt(); + (p.x / d * r, p.y / d * r) + } +} + +impl SystemMap { + fn draw_orrery(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let s = &ctx.system; + let day = ctx.day; + // One unit per braille dot: two across a cell, four down, and dots + // are about square. + let half_w = f64::from(area.width); + let half_h = 2.0 * f64::from(area.height); + let scale = Scale { radius: half_w.min(half_h) * 0.92, extent: s.extent() }; + + let trip = (self.selected != ctx.location).then(|| Self::trip(ctx, self.selected)); + let canvas = Canvas::default() + .marker(Marker::Braille) + .background_color(Color::Reset) + .x_bounds([-half_w, half_w]) + .y_bounds([-half_h, half_h]) + .paint(|c| { + draw_orbits(c, s, &scale); + c.layer(); + if let Some(days) = trip { + let from = scale.point(s.position(ctx.location, day)); + let to = scale.point(s.position(self.selected, day + days)); + let steps = ((to.0 - from.0).hypot(to.1 - from.1) / 3.0) as usize; + let dots: Vec<(f64, f64)> = (0..=steps) + .map(|i| { + let t = i as f64 / steps.max(1) as f64; + (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t) + }) + .collect(); + c.draw(&Points { coords: &dots, color: ACCENT }); + c.print(to.0, to.1, Span::raw("◌").fg(ACCENT)); + } + self.draw_bodies(c, ctx, &scale); + }); + frame.render_widget(canvas, area); + } + + fn draw_bodies(&self, c: &mut Context, ctx: &Ctx, scale: &Scale) { + let s = &ctx.system; + let day = ctx.day; + c.print(0.0, 0.0, Span::raw("☼").fg(sky::rgb(s.star.rgb)).bold()); + let mut labels = Vec::new(); + for (i, place) in s.places.iter().enumerate() { + let here = i == ctx.location; + let chosen = i == self.selected; + let (x, y) = match place.kind { + PlaceKind::Planet(p) => { + let (x, y) = scale.point(s.planets[p].orbit.position(day)); + let glyph = if here { "@" } else { "●" }; + let colour = if here { Color::White } else { sky::rgb(s.planets[p].kind.rgb) }; + c.print(x, y, Span::raw(glyph).fg(colour).bold()); + if let Landing::Capital(_) = s.landing(i) { + c.print(x + 2.0, y, Span::raw("⌂").fg(CAPITAL)); + } + (x, y) + } + PlaceKind::Moon(p, m) => { + let (px, py) = scale.point(s.planets[p].orbit.position(day)); + let moon = &s.planets[p].moons[m]; + let dir = moon.orbit.position(day).normalised(); + let r = 4.0 + 2.5 * m as f64; + let (x, y) = (px + dir.x * r, py + dir.y * r); + let colour = + if here || chosen { Color::White } else { sky::rgb(moon.kind.rgb) }; + if here { + c.print(x, y, Span::raw("@").fg(Color::White).bold()); + } else if let Landing::Capital(_) = s.landing(i) { + c.print( + x, + y, + Span::raw("⌂").fg(if chosen { Color::White } else { CAPITAL }), + ); + } else { + c.draw(&Points { coords: &[(x, y)], color: colour }); + } + (x, y) + } + PlaceKind::Belt => { + let (x, y) = scale.point(s.belt.region.position(day)); + if here { + c.print(x, y, Span::raw("@").fg(Color::White).bold()); + } else if chosen { + c.print(x, y, Span::raw("+").fg(ACCENT)); + } + (x, y) + } + PlaceKind::Station(k) => { + let (x, y) = scale.point(s.position(i, day)); + let (_, colour) = sky::station_art(s.stations[k].culture); + let glyph = if here { "@" } else { "#" }; + c.print( + x, + y, + Span::raw(glyph).fg(if here { Color::White } else { sky::rgb(colour) }), + ); + (x, y) + } + }; + if chosen || here { + let style = + if chosen { Style::new().fg(ACCENT) } else { Style::new().fg(Color::White) }; + labels.push((x + 4.0, y, Span::styled(format!(" {}", place.name), style))); + } + } + // Names last, so nothing nearby is drawn over them. + for (x, y, label) in labels { + c.print(x, y, label); + } + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let s = &ctx.system; + let block = Block::bordered() + .title(format!(" the {} system · day {:.1} ", s.name, ctx.day)) + .title_bottom( + Line::from(format!( + " {} of {} · fuel {}/{} ", + capitalise(s.kind.name()), + s.culture.name(), + ctx.hold.fuel, + ctx.hold.fuel_capacity + )) + .fg(DIM), + ) + .title_style(Style::new().fg(ACCENT).bold()) + .border_style(Style::new().fg(DIM)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let hints = keys::packed( + &[ + ("↑↓", "choose"), + ("enter", "travel"), + (".", "wait a day"), + ("g", "star map"), + ("esc", "back"), + ], + inner.width, + ); + let [list, about, keys] = Layout::vertical([ + Constraint::Min(3), + Constraint::Length(ABOUT_HEIGHT), + Constraint::Length(1 + hints.len() as u16), + ]) + .areas(inner); + + // Scroll to keep the choice in view. + let rows = usize::from(list.height); + let first = self + .selected + .saturating_sub(rows.saturating_sub(1) / 2) + .min(s.places.len().saturating_sub(rows)); + let wrecks = ctx.wrecks(); + let lines: Vec<Line> = s + .places + .iter() + .enumerate() + .skip(first) + .take(rows) + .map(|(i, place)| { + let indent = match place.kind { + PlaceKind::Moon(..) | PlaceKind::Station(_) => " ", + _ => "", + }; + let (mark, colour) = mark(s.landing(i)); + let days = if i == ctx.location { 0.0 } else { Self::trip(ctx, i) }; + let trip = if i == ctx.location { + "you're here".to_string() + } else { + format!("{days:.1} d") + }; + // Bright if there's a wreck with time to spare; faint if all + // that's there are beacons on their last legs, which wait + // only if you go straight there. + let here = wrecks.iter().filter(|w| w.place == i); + let worth = here.clone().any(|w| w.leaves - ctx.day > FAILING); + let beacon = match (here.count(), worth) { + (0, _) => Span::raw(" "), + (_, true) => Span::raw("!").fg(WRECK), + (_, false) => Span::raw("!").fg(FAINT_WRECK), + }; + let name_width = usize::from(list.width).saturating_sub(16 + indent.len()); + let name: String = place.name.chars().take(name_width).collect(); + let mut spans = vec![ + Span::raw(format!(" {mark} ")).fg(colour), + Span::raw(format!("{indent}{name:<name_width$}")), + beacon, + Span::raw(format!("{trip:>12}")).fg(DIM), + ]; + if i == self.selected { + for span in &mut spans { + span.style = span.style.add_modifier(Modifier::REVERSED); + } + } + Line::from(spans) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), list); + + frame.render_widget( + // Wrapped already, so that wrapped lines stay indented. + Paragraph::new(self.about(ctx, usize::from(about.width))), + about, + ); + let legend = Line::from(vec![ + Span::raw("⌂").fg(CAPITAL), + Span::raw(" capital ").fg(DIM), + Span::raw("¤").fg(MARKET), + Span::raw(" trade only ").fg(DIM), + Span::raw("·").fg(DIM), + Span::raw(" neither ").fg(DIM), + Span::raw("!").fg(WRECK), + Span::raw(" wreck").fg(DIM), + ]); + let mut lines = vec![legend]; + lines.extend(hints); + frame.render_widget(Paragraph::new(lines), keys); + } + + /// What's known about the chosen place: what it is, what you'll find if + /// you land, the trip there, and a survey of what it's like. + fn about(&self, ctx: &Ctx, width: usize) -> Vec<Line<'static>> { + let s = &ctx.system; + let i = self.selected; + let place = &s.places[i]; + let who = |people: Option<Culture>, home: bool| match people { + Some(c) if home && s.kind != crate::space::system::SystemKind::Seat => { + format!("chief world of {}", c.name()) + } + Some(c) if home => format!("homeworld of {}", c.name()), + Some(c) => format!("settled by {}", c.name()), + None => "uninhabited".to_string(), + }; + let (what, orbit_line) = match place.kind { + PlaceKind::Planet(p) => { + let planet = &s.planets[p]; + ( + format!("{} · {}", planet.kind.name, who(planet.people, p == s.home)), + format!( + "{:.2} AU from the star; a year of {:.0} days", + planet.orbit.a, planet.orbit.period + ), + ) + } + PlaceKind::Moon(p, m) => { + let moon = &s.planets[p].moons[m]; + ( + format!("{} · {}", moon.kind.name, who(moon.people, false)), + format!( + "a moon of {}, going round every {:.1} days", + s.planets[p].name, moon.orbit.period + ), + ) + } + PlaceKind::Belt => ( + "an asteroid belt: good mining".to_string(), + format!("from {:.2} to {:.2} AU from the star", s.belt.inner, s.belt.outer), + ), + PlaceKind::Station(k) => { + let st = &s.stations[k]; + let point = if st.leading { "L4" } else { "L5" }; + ( + format!("a {} station", st.culture.adjective()), + format!( + "at {}'s {point} point, sixty degrees {} it", + s.planets[st.planet].name, + if st.leading { "ahead of" } else { "behind" } + ), + ) + } + }; + let prose = |text: String, style: Style| wrap(Vec::new(), &[(text, style)], width); + let mut lines = vec![Line::from(place.name.clone()).fg(ACCENT).bold()]; + lines.extend(prose(what, Style::new())); + lines.extend(landing_line(s, i, width)); + if i != ctx.location { + let days = Self::trip(ctx, i); + let now = s.position(ctx.location, ctx.day); + let there = s.position(i, ctx.day + days); + lines.extend(prose( + format!( + "{:.2} AU away by the time you arrive: {:.1} days, on day {:.1}", + now.distance(there), + days, + ctx.day + days + ), + Style::new(), + )); + } + let arrival = if i == ctx.location { ctx.day } else { ctx.day + Self::trip(ctx, i) }; + for w in ctx.wrecks().iter().filter(|w| w.place == i) { + let found = if w.calling(ctx.day) { + format!( + "A distress beacon: {}, a {}. {}", + w.name, + w.description, + beacon_age(w, ctx.day) + ) + } else { + format!( + "A wreck: {}, a {}. Its beacon failed on your way here, but you came by \ + its last fix.", + w.name, w.description + ) + }; + let mut phrases = vec![(found, Style::new().fg(Color::Rgb(255, 120, 100)))]; + if let Some(warning) = beacon_warning(w, ctx.day, arrival) { + phrases.push((format!(" {warning}"), Style::new().fg(FAINT_WRECK))); + } + lines.extend(wrap(Vec::new(), &phrases, width)); + } + lines.push(Line::default()); + let station = matches!(place.kind, PlaceKind::Station(_)); + let giant = match place.kind { + PlaceKind::Planet(p) => s.planets[p].kind.giant, + _ => false, + }; + lines.extend(facts(&survey::survey(s, i, arrival), station, giant, width)); + lines.push(Line::default()); + lines.extend(prose(orbit_line, Style::new().fg(DIM))); + lines + } +} + +/// How long a wreck's beacon has been calling: "Calling for 12 days." +pub(crate) fn beacon_age(w: &Wreck, day: f64) -> String { + match (day - w.arrived).floor() { + ..1.0 => "It started calling today.".into(), + 1.0 => "Calling since yesterday.".into(), + n => format!("Calling for {n:.0} days."), + } +} + +/// Whether a beacon is failing, or will fail before you `arrive`. A wreck +/// whose beacon has failed already is one you came by its last fix: there's +/// nothing to warn of. +pub(crate) fn beacon_warning(w: &Wreck, day: f64, arrive: f64) -> Option<String> { + let left = w.leaves - day; + if left <= 0.0 { + None + } else if w.leaves <= arrive { + Some( + "It'll fail on the way, but go straight there and you'll find it by its last fix." + .into(), + ) + } else if left <= FAILING { + Some(match left.floor() { + ..1.0 => "Failing: hours left.".into(), + 1.0 => "Failing: a day left, maybe two.".into(), + n => format!("Failing: {n:.0} days left, maybe less."), + }) + } else { + None + } +} + +/// How a place is marked in the list: what you'll find if you land. +fn mark(landing: Landing) -> (&'static str, Color) { + match landing { + Landing::Capital(_) => ("⌂", CAPITAL), + Landing::Market(_) => ("¤", MARKET), + Landing::Aloft(_) | Landing::Wilds | Landing::Nowhere => ("·", DIM), + } +} + +/// What you'll find if you land or dock at a place, in a sentence. +fn landing_line(s: &System, place: usize, width: usize) -> Vec<Line<'static>> { + let landing = s.landing(place); + let (mark, colour) = mark(landing); + let (tag, rest) = match landing { + Landing::Capital(c) => ( + "Capital", + format!( + ": {}. Land and walk in to its broker, doctor, scholar, tavern and shipyard, \ + which sells fuel.", + town::name(town::seed_for(s.seed, place), c) + ), + ), + Landing::Market(_) => { + ("Trade only", ": dock to sell ore and buy fuel. No city, no shops.".to_string()) + } + Landing::Aloft(c) => ( + "Neither", + format!(": {} live up in its clouds, but there's no ground to land on.", c.name()), + ), + Landing::Wilds => { + ("Neither", ": no one lives here to trade with. Land and look around.".to_string()) + } + Landing::Nowhere if s.places[place].kind == PlaceKind::Belt => { + ("Neither", ": nowhere to land, only rocks to mine.".to_string()) + } + Landing::Nowhere => { + ("Neither", ": no one lives here, and there's no ground to land on.".to_string()) + } + }; + let head = vec![Span::raw(format!("{mark} ")).fg(colour)]; + let phrases = [(format!("{tag}{rest}"), Style::new())]; + let mut lines = wrap(head, &phrases, width); + // Colour the tag, which is always the first word or two. + if let Some(first) = lines.first_mut() { + let words = tag.split(' ').count(); + for span in first.spans.iter_mut().skip(1).step_by(2).take(words) { + span.style = Style::new().fg(colour).bold(); + } + } + lines +} + +/// The survey, as labelled rows. +fn facts(survey: &Survey, station: bool, giant: bool, width: usize) -> Vec<Line<'static>> { + let plain = Style::new(); + let dim = Style::new().fg(DIM); + let mut lines = Vec::new(); + if let Some(t) = survey.temperature { + lines.push(thermometer(t)); + let range = (t.hottest - t.coldest >= 2.0).then(|| { + format!("{} to {} °C", survey::celsius(t.coldest), survey::celsius(t.hottest)) + }); + let detail = match (giant, range) { + (true, Some(range)) => Some(format!("{range} at the cloud tops")), + (true, None) => Some("at the cloud tops".to_string()), + (false, Some(range)) => Some(format!("{range} over its year")), + (false, None) => None, + }; + if let Some(detail) = detail { + lines.extend(fact("", &[(detail, dim)], width)); + } + } + if let Some(day) = &survey.day { + let mut text = day.clone(); + if let Some(seasons) = survey.seasons { + text = format!("{text}; {seasons}"); + } + lines.extend(fact(if station { "spin" } else { "day" }, &[(text, plain)], width)); + } + if let Some(g) = survey.gravity { + let amount = if g < 0.1 { format!("{g:.2} g") } else { format!("{g:.1} g") }; + let feel = match g { + g if g < 0.3 => "featherlight", + g if g < 0.75 => "light", + g if g < 1.25 => "", + g if g < 1.8 => "heavy", + _ => "crushing", + }; + let mut parts = vec![(amount, plain)]; + if !feel.is_empty() { + parts.push((format!("· {feel}"), dim)); + } + if station { + parts.push(("· from its spin".to_string(), dim)); + } + lines.extend(fact("pull", &parts, width)); + } + let suit_up = ("suit up".to_string(), Style::new().fg(SUIT_UP)); + let air = match survey.air { + Some((what, true)) => { + vec![ + (what.to_string(), plain), + ("·".into(), dim), + ("breathable".into(), Style::new().fg(CAPITAL)), + ] + } + Some((what, false)) => vec![(what.to_string(), plain), ("·".into(), dim), suit_up], + None => vec![("none".to_string(), plain), ("·".into(), dim), suit_up], + }; + lines.extend(fact("air", &air, width)); + lines.extend(fact("sky", &[(survey.sky.clone(), plain)], width)); + if let Some(n) = survey.population { + let mut text = headcount(n); + if giant { + text.push_str(", in cloud cities"); + } + lines.extend(fact(if station { "aboard" } else { "people" }, &[(text, plain)], width)); + } + lines +} + +/// A labelled row of the survey. +fn fact(label: &str, phrases: &[(String, Style)], width: usize) -> Vec<Line<'static>> { + let head = vec![Span::raw(format!("{label:<LABEL_WIDTH$}")).fg(DIM)]; + wrap(head, phrases, width) +} + +/// Phrases joined by spaces, each in its own style, after `head` and +/// word-wrapped to `width`, with the overflow indented to line up under the +/// first line's text. Each word is its own span, after the head. +pub(crate) fn wrap( + head: Vec<Span<'static>>, + phrases: &[(String, Style)], + width: usize, +) -> Vec<Line<'static>> { + let indent: usize = head.iter().map(|s| s.content.chars().count()).sum(); + let room = width.saturating_sub(indent).max(10); + let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()]; + let mut used = 0; + for (text, style) in phrases { + for word in text.split_whitespace() { + let w = word.chars().count(); + if used > 0 && used + 1 + w > room { + rows.push(Vec::new()); + used = 0; + } + let row = rows.last_mut().unwrap(); + if used > 0 { + row.push(Span::raw(" ")); + used += 1; + } + row.push(Span::styled(word.to_string(), *style)); + used += w; + } + } + let mut head = Some(head); + rows.into_iter() + .map(|spans| { + let mut line = head.take().unwrap_or_else(|| vec![Span::raw(" ".repeat(indent))]); + line.extend(spans); + Line::from(line) + }) + .collect() +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +/// Cells in the thermometer. +const THERMOMETER: usize = 14; +/// The thermometer's ends, in kelvin. It's marked on a log scale, so the +/// temperate middle isn't squeezed. +const COLDEST: f64 = 50.0; +const HOTTEST: f64 = 1500.0; + +/// A thermometer from deep cold to molten, filled up to the temperature +/// now and lit more faintly over the rest of its year's swing; then the +/// temperature and a word for it. +fn thermometer(t: Temperature) -> Line<'static> { + let cell = |kelvin: f64| { + let k = (kelvin / COLDEST).ln() / (HOTTEST / COLDEST).ln(); + (k.clamp(0.0, 1.0) * (THERMOMETER - 1) as f64).round() as usize + }; + let (low, high, now) = (cell(t.coldest), cell(t.hottest), cell(t.now)); + let mut spans = vec![Span::raw(format!("{:<LABEL_WIDTH$}", "temp")).fg(DIM)]; + for i in 0..THERMOMETER { + let kelvin = COLDEST * (HOTTEST / COLDEST).powf(i as f64 / (THERMOMETER - 1) as f64); + let colour = heat(kelvin); + spans.push(if i == now { + Span::raw("●").fg(Color::White).bold() + } else if i < now { + Span::raw("━").fg(sky::rgb(colour)) + } else if (low..=high).contains(&i) { + Span::raw("━").fg(sky::shade(colour, 0.6)) + } else { + Span::raw("─").fg(sky::shade(colour, 0.35)) + }); + } + let colour = sky::rgb(heat(t.now)); + spans.push(Span::raw(format!(" {} °C ", survey::celsius(t.now))).fg(colour).bold()); + spans.push(Span::raw(t.word()).fg(colour)); + Line::from(spans) +} + +/// The colour of a temperature: blue through ice-white and green to red. +fn heat(kelvin: f64) -> Rgb { + const STOPS: [(f64, Rgb); 7] = [ + (50.0, (96, 112, 236)), + (150.0, (120, 178, 255)), + (240.0, (176, 226, 244)), + (FREEZING + 15.0, (128, 218, 138)), + (320.0, (240, 210, 96)), + (450.0, (246, 142, 64)), + (900.0, (236, 72, 52)), + ]; + let k = kelvin.clamp(STOPS[0].0, STOPS[STOPS.len() - 1].0); + let i = STOPS.iter().rposition(|&(at, _)| at <= k).unwrap().min(STOPS.len() - 2); + let ((a, ca), (b, cb)) = (STOPS[i], STOPS[i + 1]); + let f = ((k / a).ln() / (b / a).ln()).clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * f).round() as u8; + (mix(ca.0, cb.0), mix(ca.1, cb.1), mix(ca.2, cb.2)) +} + +/// "2.1 billion", "48 million", "12,000". +fn headcount(n: u64) -> String { + let short = |x: f64, unit: &str| { + let text = if x < 10.0 { format!("{x:.1}") } else { format!("{x:.0}") }; + format!("{} {unit}", text.trim_end_matches(".0")) + }; + match n { + n if n >= 1_000_000_000 => short(n as f64 / 1e9, "billion"), + n if n >= 1_000_000 => short(n as f64 / 1e6, "million"), + n => { + let digits = n.to_string(); + let mut out = String::new(); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out + } + } +} + +fn draw_orbits(c: &mut Context, s: &System, scale: &Scale) { + for planet in &s.planets { + let points: Vec<(f64, f64)> = planet.orbit.outline(240).map(|p| scale.point(p)).collect(); + c.draw(&Points { coords: &points, color: ORBIT }); + } + // The belt: a scatter of dots, the same every time. + let belt: Vec<(f64, f64)> = (0..700) + .map(|i| { + let angle = sky::hash01(s.seed, i, 1) * TAU; + let r = s.belt.inner + (s.belt.outer - s.belt.inner) * sky::hash01(s.seed, i, 2); + scale.point(Vec2::new(r * angle.cos(), r * angle.sin())) + }) + .collect(); + c.draw(&Points { coords: &belt, color: Color::Rgb(104, 96, 86) }); +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + + fn press(map: &mut SystemMap, ctx: &mut Ctx, code: KeyCode) -> Transition { + map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + #[test] + fn travelling_takes_days_and_moves_you() { + let mut ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + let start = ctx.location; + press(&mut map, &mut ctx, KeyCode::Down); + let destination = map.selected; + let days = SystemMap::trip(&ctx, destination); + assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop)); + assert_eq!(ctx.location, destination); + assert_ne!(ctx.location, start); + assert!((ctx.day - days).abs() < 1e-9 && days > 0.0); + assert_eq!(ctx.departed, Some(0.0)); + } + + #[test] + fn a_beacon_that_fails_on_the_way_still_leads_you_there() { + let ctx = test_ctx(); + let w = ctx.system.wrecks_on(0.0)[0].clone(); + let warning = beacon_warning(&w, 0.0, w.leaves + 1.0).unwrap(); + assert!(warning.contains("last fix"), "{warning}"); + assert_eq!(beacon_warning(&w, 0.0, 0.5), None, "plenty of time"); + let failed = w.leaves + 1.0; + assert_eq!(beacon_warning(&w, failed, failed), None, "you're there already"); + } + + #[test] + fn choosing_where_you_are_goes_nowhere() { + let mut ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + press(&mut map, &mut ctx, KeyCode::Enter); + assert_eq!(ctx.day, 0.0); + press(&mut map, &mut ctx, KeyCode::Char('.')); + assert_eq!(ctx.day, 1.0, "but you can wait"); + } + + #[test] + fn numbers_read_naturally() { + assert_eq!(headcount(2_100_000_000), "2.1 billion"); + assert_eq!(headcount(3_000_000_000), "3 billion"); + assert_eq!(headcount(48_000_000), "48 million"); + assert_eq!(headcount(12_000), "12,000"); + assert_eq!(headcount(870), "870"); + } + + #[test] + fn the_map_says_where_the_capitals_are() { + let ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + map.selected = ctx.system.home_place(); + let text: String = map + .about(&ctx, 44) + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.to_string())) + .collect(); + assert!(text.contains("⌂ Capital: "), "{text}"); + assert!(text.contains("°C"), "{text}"); + let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + assert_eq!(mark(ctx.system.landing(belt)).0, "·"); + } + + #[test] + fn the_list_wraps_round() { + let mut ctx = test_ctx(); + let mut map = SystemMap::new(&ctx); + map.selected = 0; + press(&mut map, &mut ctx, KeyCode::Up); + assert_eq!(map.selected, ctx.system.places.len() - 1); + } +} diff --git a/src/scenes/title.rs b/src/scenes/title.rs index 7c75888..898452e 100644 --- a/src/scenes/title.rs +++ b/src/scenes/title.rs @@ -1,4 +1,6 @@ -//! Title screen: a turning orrery over a drifting starfield. +//! Title screen: a turning orrery over a drifting starfield, and the way in: +//! carry on as the character you last played, make a new one, or load +//! another. //! //! Every universe seed gets its own orrery, and everything here moves as a //! function of `ctx.ticks`, so it doubles as a check that the tick loop runs. @@ -16,10 +18,15 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::widgets::canvas::{Canvas, Points}; +use super::character::{Create, Sheet}; use super::derelict::Derelict; +use super::flight; use super::forge::Forge; +use super::keys::ACCENT; +use super::saves::Saves; use super::{Scene, Transition}; use crate::app::{Ctx, TICKS_PER_SECOND}; +use crate::save; use crate::seed; const LOGO: [&str; 4] = [ @@ -64,8 +71,13 @@ struct Planet { } pub struct Title { + /// Whose orrery this is. Loading or making a character can change + /// universe under it. + universe: u64, stars: Vec<Star>, planets: Vec<Planet>, + /// Whether there are saved characters to load. + saved: bool, } impl Title { @@ -92,7 +104,8 @@ impl Title { } }) .collect(); - Title { stars, planets } + let saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); + Title { universe: ctx.seed, stars, planets, saved } } fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) { @@ -143,16 +156,43 @@ impl Title { } } +/// The menu: key, what it does. +fn menu(ctx: &Ctx, saved: bool) -> Vec<(&'static str, String)> { + let mut items = Vec::new(); + if ctx.slot.is_some() { + let c = &ctx.character; + items.push(("enter", format!("carry on as {}, of {}", c.name, c.ship_name()))); + items.push(("c", "your character".into())); + } + items.push(("n", "a new character".into())); + if saved { + items.push(("l", "load a character".into())); + } + if ctx.slot.is_some() { + items.push(("d", "board a derelict, straight away".into())); + } + items.push(("g", "the name forge".into())); + items.push(("q", "leave".into())); + items +} + impl Scene for Title { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + let playing = ctx.slot.is_some(); match key.code { + KeyCode::Enter if playing => Transition::Open(flight::resume(ctx)), + KeyCode::Enter => Transition::Push(Box::new(Create::new(ctx))), KeyCode::Char(c) => match c.to_ascii_lowercase() { - 'd' => { + 'f' if playing => Transition::Open(flight::resume(ctx)), + 'c' if playing => Transition::Push(Box::new(Sheet::new())), + 'n' => Transition::Push(Box::new(Create::new(ctx))), + 'l' if self.saved => Transition::Push(Box::new(Saves::new(ctx))), + 'd' if playing => { ctx.boarded += 1; let ship = seed::derive(ctx.seed, "derelict", ctx.boarded); - Transition::Push(Box::new(Derelict::new(ship))) + Transition::Push(Box::new(Derelict::board(ship, ctx))) } - 'n' => Transition::Push(Box::new(Forge::new(ctx))), + 'g' => Transition::Push(Box::new(Forge::new(ctx))), 'q' => Transition::Quit, _ => Transition::None, }, @@ -161,17 +201,26 @@ impl Scene for Title { } } + fn on_top(&mut self, ctx: &Ctx) { + if ctx.seed != self.universe { + *self = Title::new(ctx); + } else { + self.saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); + } + } + fn render(&self, frame: &mut Frame, ctx: &Ctx) { let seconds = ctx.ticks as f64 / f64::from(TICKS_PER_SECOND); let area = frame.area(); self.draw_stars(frame.buffer_mut(), area, seconds); + let items = menu(ctx, self.saved); let [sky, logo, _, menu, hold, footer] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(LOGO.len() as u16), Constraint::Length(1), - Constraint::Length(4), - Constraint::Length(1), + Constraint::Length(items.len() as u16), + Constraint::Length(2), Constraint::Length(1), ]) .areas(area); @@ -181,44 +230,95 @@ impl Scene for Title { let logo_lines: Vec<Line> = LOGO.iter().map(|l| Line::from(*l).bold()).collect(); frame.render_widget(Paragraph::new(logo_lines).centered().fg(Color::Yellow), logo); - let item = |key: &'static str, label: &'static str, built: bool| { - let style = if built { Style::new() } else { Style::new().fg(Color::DarkGray) }; - let mut spans = vec![Span::styled(key, style.bold()), Span::styled(label, style)]; - if !built { - spans.push(Span::styled(" (not yet built)", style.italic())); - } - Line::from(spans) - }; - let [menu] = Layout::horizontal([Constraint::Length(46)]).flex(Flex::Center).areas(menu); - frame.render_widget( - Paragraph::new(vec![ - item("[d] ", "board a derelict", true), - item("[n] ", "the name forge", true), - item("[f] ", "fly a star system", false), - item("[q] ", "leave", true), - ]), - menu, - ); - - let h = &ctx.hold; - if !h.artifacts.is_empty() || h.scrip > 0 { - let plural = if h.artifacts.len() == 1 { "" } else { "s" }; + // Keys in a column, in the colour key hints are everywhere. + let keys = items.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0) + 2; + let width = items.iter().map(|(_, l)| keys + l.chars().count()).max(); + let [menu] = Layout::horizontal([Constraint::Length(width.unwrap_or(0) as u16)]) + .flex(Flex::Center) + .areas(menu); + let lines: Vec<Line> = items + .into_iter() + .map(|(key, label)| { + Line::from(vec![ + Span::raw(format!("{key:<keys$}")).fg(ACCENT).bold(), + Span::raw(label), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), menu); + + if ctx.slot.is_some() { + let h = &ctx.hold; + let plural = if h.artifacts() == 1 { "" } else { "s" }; + let place = match &ctx.crawl { + Some(c) => format!("aboard {}", c.name), + None => format!("at {}", ctx.system.places[ctx.location].name), + }; let text = format!( - "your hold: {} artifact{plural}, {} ¤ of salvage", - h.artifacts.len(), + "day {:.1} {place} in {} · {} artifact{plural}, {} t of ore, {} ¤", + ctx.day, + ctx.system.name, + h.artifacts(), + h.ore_tons(), h.scrip ); + let [_, hold] = Layout::vertical([Constraint::Length(1); 2]).areas(hold); frame.render_widget(Paragraph::new(text).centered().fg(Color::Yellow), hold); } - let keys = if ctx.keyboard.key_release { - "held keys detectable (kitty keyboard protocol)" - } else { - "held keys not detectable: flight will need a throttle" + let footer_text = match &ctx.save_error { + Some(problem) => Paragraph::new(problem.as_str()).centered().light_red(), + None => { + let keys = if ctx.keyboard.key_release { + "held keys detectable (kitty keyboard protocol)" + } else { + "held keys not detectable: flight will need a throttle" + }; + Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray() + } + }; + frame.render_widget(footer_text, footer); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + + fn press(title: &mut Title, ctx: &mut Ctx, code: KeyCode) -> Transition { + title.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + #[test] + fn without_a_character_you_make_one() { + let mut ctx = test_ctx(); + let mut title = Title::new(&ctx); + let keys: Vec<&str> = menu(&ctx, false).iter().map(|&(key, _)| key).collect(); + assert_eq!(keys, ["n", "g", "q"]); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Enter), Transition::Push(_))); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('d')), Transition::None)); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('l')), Transition::None)); + } + + #[test] + fn with_one_you_carry_on_where_they_were() { + let mut ctx = test_ctx(); + ctx.slot = Some("someone.json".into()); + let mut title = Title::new(&ctx); + let c = &ctx.character; + let carry_on = format!("carry on as {}, of {}", c.name, c.ship_name()); + assert_eq!(menu(&ctx, true)[0], ("enter", carry_on)); + let Transition::Open(scenes) = press(&mut title, &mut ctx, KeyCode::Enter) else { + panic!("enter carries on"); }; - frame.render_widget( - Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(), - footer, - ); + assert_eq!(scenes.len(), 1, "flying, as they weren't landed"); + + // Whoever comes back to the title may be from another universe. + ctx.begin(99, ctx.character.clone()); + title.on_top(&ctx); + assert_eq!(title.universe, 99); } } diff --git a/src/scenes/town.rs b/src/scenes/town.rs new file mode 100644 index 0000000..abf4e05 --- /dev/null +++ b/src/scenes/town.rs @@ -0,0 +1,2509 @@ +//! Walking around a capital city, going into its buildings, and trading in +//! its shops. The city and the insides of its buildings are generated in +//! `town`; this moves you about, lets people wander and talk, and runs the +//! shops' counters. +//! +//! Out in the streets you see the city from high up: roofs, with signs on +//! the shops. Bump a door and the building's inside takes over the screen, +//! at its own, closer scale. `m` shows the whole city at once. +//! +//! People who wander step aside as you walk through them; `t` talks to +//! someone beside you. People who stay put (keepers, stallholders, guards, +//! anyone sitting down) are in the way, and bumping them talks to them, or +//! opens their shop. +//! +//! `i` opens the inventory anywhere. What you put down stays where it is +//! until you leave the city. + +use std::collections::{HashMap, HashSet}; + +use rand::RngExt; +use rand::seq::IndexedRandom; +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; + +use super::character::Sheet; +use super::derelict::camera; +use super::inventory::{self, Beside, Command, Counter, Inventory, Reply, View}; +use super::keys::{self, ACCENT, DIM}; +use super::pause::{Choice, Pause}; +use super::sky::rgb; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold, Kept}; +use crate::dungeon::Pos; +use crate::dungeon::armour::Armor; +use crate::dungeon::arms::Weapon; +use crate::items::{self, Item, Stack}; +use crate::names::Culture; +use crate::seed::{self, Rng}; +use crate::space::survey; +use crate::space::system::{PlaceKind, Rgb, Terrain}; +use crate::town::{ + self, Interior, Kind, Land, Person, Pond, Role, Shop, ShopKind, Site, Thing, Tile, Town, words, +}; +use crate::trade; + +const PANEL_WIDTH: u16 = 32; +const LOG_HEIGHT: u16 = 6; +/// On average, how many ticks pass between one person's steps. +const WANDER_TICKS: u32 = 45; +/// The most steps one run takes. +const RUN_LIMIT: usize = 80; + +const SHIP_ART: [&str; 3] = [" /^\\ ", "<|o|>", " v v "]; +const SIGN: Rgb = (255, 226, 150); + +/// How a city looks: how its culture builds, a glyph and colour for each +/// kind of paving and wall, and the world it stands on. +pub(crate) struct Theme { + world: World, + street: (char, Rgb), + square: (char, Rgb), + wall: (char, Rgb), + city_wall: (char, Rgb), + /// Roofs come in a few colours, so neighbours stand apart. + roofs: [Rgb; 3], + floor: (char, Rgb), + rug: Rgb, + bed: Rgb, + centrepiece: (char, Rgb), + /// The pipes or scrap a culture leaves lying about, if it does. + decor: Option<(char, Rgb)>, + stall: (char, Rgb), + /// The altar, and the temple's mark on the city map. + altar: (char, Rgb), + folk: Rgb, +} + +/// How the ground looks, and what grows and stands on it. +struct World { + ground: (char, Rgb), + /// Yards, courtyards and parks. + garden: (char, Rgb), + tree: (char, Rgb), + /// Outside the walls: the two sorts of `Tile::Wild`. + wild: [(char, Rgb); 2], + pond: Pond, + /// The sea, and its lighter crests. + sea: [Rgb; 2], + /// Whether the second sort of wild thing burns: a forge world's vents. + vents: bool, +} + +const FROST: Rgb = (226, 236, 246); +const STRAW: Rgb = (182, 152, 96); + +/// `a` mixed with `k` of `b`. +fn mix((r, g, b): Rgb, (r2, g2, b2): Rgb, k: f64) -> Rgb { + let f = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * k).round() as u8; + (f(r, r2), f(g, g2), f(b, b2)) +} + +/// How a city's world looks: its kind of ground, frosted or parched by the +/// weather today, and tinted a little its own way. +fn world(t: &Town) -> World { + let land = t.land; + let mut w = match land.terrain { + Terrain::Desert => World { + ground: ('·', (204, 170, 112)), + garden: (',', (166, 156, 92)), + tree: ('♣', (128, 162, 78)), + wild: [('ψ', (118, 156, 86)), ('●', (170, 128, 88))], + pond: Pond::Water, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Ice => World { + ground: ('·', (214, 228, 242)), + garden: ('"', (150, 204, 214)), + tree: ('♠', (130, 196, 222)), + wild: [('▲', (196, 226, 248)), ('●', (140, 150, 168))], + pond: Pond::Ice, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Ocean => World { + ground: ('·', (104, 100, 112)), + garden: ('"', (86, 168, 116)), + tree: ('♣', (60, 150, 100)), + wild: [('●', (82, 80, 92)), ('§', (96, 136, 84))], + pond: Pond::Water, + sea: [(40, 92, 168), (110, 170, 220)], + vents: false, + }, + Terrain::Jungle => World { + ground: ('"', (58, 120, 62)), + garden: ('"', (74, 160, 72)), + tree: ('♣', (44, 140, 56)), + wild: [('♣', (38, 112, 48)), ('♠', (60, 132, 72))], + pond: Pond::Water, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Barren | Terrain::Clouds => World { + ground: ('·', (134, 128, 122)), + garden: (';', (176, 102, 70)), + tree: ('♦', (156, 124, 214)), + wild: [('▲', (142, 134, 126)), ('♦', (112, 98, 152))], + pond: Pond::Dry, + sea: [(0, 0, 0); 2], + vents: false, + }, + Terrain::Forge => World { + ground: ('·', (92, 78, 76)), + garden: (',', (164, 118, 64)), + tree: ('τ', (206, 128, 92)), + wild: [('▲', (84, 74, 74)), ('^', (255, 130, 50))], + pond: Pond::Lava, + sea: [(0, 0, 0); 2], + vents: true, + }, + }; + w.pond = land.pond(); + let tint = t.tint(); + // How much frost and drought show on each: living things wither, the + // sea only scums with ice. + let paint = |c: Rgb, (frost, parch): (f64, f64)| { + let c = mix(c, FROST, frost * land.frost); + let c = mix(c, STRAW, parch * land.drought); + let k = |v: u8, i: usize| (f64::from(v) * tint[i]).round().clamp(0.0, 255.0) as u8; + (k(c.0, 0), k(c.1, 1), k(c.2, 2)) + }; + let (living, lying) = ((0.7, 0.6), (0.7, 0.15)); + let ground = if land.terrain == Terrain::Jungle { living } else { lying }; + w.ground.1 = paint(w.ground.1, ground); + w.garden.1 = paint(w.garden.1, living); + w.tree.1 = paint(w.tree.1, living); + for wild in &mut w.wild { + wild.1 = paint(wild.1, ground); + } + w.sea = w.sea.map(|c| paint(c, (0.3, 0.0))); + w +} + +pub(crate) fn theme(t: &Town) -> Theme { + let world = world(t); + match t.culture { + Culture::Qaari => Theme { + world, + street: ('.', (170, 140, 96)), + square: (':', (196, 166, 116)), + wall: ('#', (214, 176, 124)), + city_wall: ('█', (176, 136, 88)), + roofs: [(170, 128, 82), (206, 190, 160), (190, 120, 70)], + floor: ('.', (130, 110, 88)), + rug: (170, 70, 60), + bed: (200, 150, 90), + centrepiece: ('≈', (96, 164, 226)), + decor: None, + stall: ('▀', (220, 120, 80)), + altar: ('O', (96, 164, 226)), + folk: (226, 196, 150), + }, + Culture::Cant => Theme { + world, + street: ('·', (132, 138, 152)), + square: ('+', (150, 152, 164)), + wall: ('#', (176, 112, 90)), + city_wall: ('▓', (120, 90, 80)), + roofs: [(120, 88, 76), (100, 104, 116), (140, 96, 64)], + floor: ('.', (120, 120, 132)), + rug: (120, 60, 50), + bed: (150, 150, 160), + centrepiece: ('Ω', (255, 140, 60)), + decor: Some(('‡', (150, 152, 172))), + stall: ('▀', (170, 120, 200)), + altar: ('Ω', (255, 140, 60)), + folk: (200, 160, 140), + }, + Culture::Houses => Theme { + world, + street: ('·', (200, 196, 210)), + square: (':', (222, 218, 232)), + wall: ('#', (226, 222, 236)), + city_wall: ('█', (190, 186, 202)), + roofs: [(160, 96, 126), (176, 90, 70), (90, 110, 140)], + floor: ('.', (184, 172, 160)), + rug: (150, 50, 80), + bed: (190, 60, 90), + centrepiece: ('≈', (110, 170, 240)), + decor: None, + stall: ('▀', (200, 120, 200)), + altar: ('†', (240, 210, 120)), + folk: (220, 200, 240), + }, + Culture::Drift => Theme { + world, + street: ('-', (110, 150, 150)), + square: (':', (130, 190, 184)), + wall: ('=', (124, 212, 200)), + city_wall: ('▓', (80, 120, 130)), + roofs: [(60, 110, 120), (120, 120, 110), (170, 110, 70)], + floor: ('.', (120, 140, 140)), + rug: (60, 120, 150), + bed: (90, 170, 170), + centrepiece: ('¥', (230, 230, 160)), + decor: Some(('%', (150, 120, 90))), + stall: ('▀', (230, 150, 90)), + altar: ('¥', (230, 230, 160)), + folk: (160, 220, 214), + }, + } +} + +/// How a culture's shopkeepers greet you. +fn salutation(culture: Culture) -> &'static str { + match culture { + Culture::Qaari => "Water and shade, traveller.", + Culture::Cant => "Be welcome, unit. State your need.", + Culture::Houses => "Your grace honours my house.", + Culture::Drift => "Hey, spacer.", + } +} + +fn pitch(kind: ShopKind) -> &'static str { + match kind { + ShopKind::Broker => "I buy ore by the ton. Show me what you've dug up.", + ShopKind::Doctor => "Medpatches, fresh. They'll keep you breathing aboard those wrecks.", + ShopKind::Scholar => { + "Bring me the strange things you find. I'll tell you what they are, \ + and buy what's worth buying." + } + ShopKind::Tavern => "Buy a round and the room will talk. Rooms always talk.", + ShopKind::Shipyard => { + "Fuel for the jump, racks for more ore, a finer lens for your laser. \ + Or a bigger tank, if you mean to go far." + } + ShopKind::Armoury => { + "Arms and armour, bought, sold and mended. Show me what you've found, or try the rack." + } + } +} + +fn shelf(kind: ShopKind) -> (char, Rgb) { + match kind { + ShopKind::Broker => ('%', (170, 140, 110)), + ShopKind::Doctor => ('!', (240, 96, 96)), + ShopKind::Scholar => ('≡', (206, 186, 140)), + ShopKind::Tavern => ('o', (170, 116, 72)), + ShopKind::Shipyard => ('¤', (150, 172, 196)), + ShopKind::Armoury => (')', (186, 190, 204)), + } +} + +/// What a door says when it won't open. +fn barred(kind: Kind) -> &'static str { + match kind { + Kind::Manor => "A servant looks out through a grille at you, and shuts it again.", + Kind::Warehouse => "Locked, and chained for good measure.", + Kind::Tenement => "The street door's locked. A face at a window watches you.", + Kind::Workshop => "A slate on the door: closed for a commission.", + _ => "The door is barred. Someone's home, but not for you.", + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Deal { + SellOre, + /// Sell the salvage kept here, all of it. + SellSalvage(Kept), + SellAllSalvage, + BuyMedpatch, + /// Have the artifact kept here identified. + Identify(Kept), + SellArtifact(Kept), + BuyRound, + BuyPack, + BuyRacks, + BuyLens, + BuyFuel, + BuyTank, +} + +struct Offer { + text: String, + deal: Deal, +} + +struct Trading { + shop: usize, + choice: usize, + /// What the keeper said to your last request. + reply: Option<String>, +} + +pub struct TownScene { + town: Town, + /// The building you're in, if you're indoors. + inside: Option<Interior>, + place: usize, + /// "Qeth", the world the town is on. + world: String, + player: Pos, + /// The way you last stepped, which is who `t` talks to first. + facing: (i32, i32), + log: Vec<String>, + /// How many things have been said, ever: the log itself stops growing. + said: usize, + trading: Option<Trading>, + /// Looking at the whole city. + map: bool, + /// Buildings you've been into. + visited: HashSet<usize>, + rumours: Vec<String>, + heard: usize, + rng: Rng, + /// Open over the city when you're looking through what you carry. + inventory: Option<Inventory>, + /// The armourer whose rack is beside you in the inventory, when you're + /// trading with one. + shopping: Option<usize>, + /// What you've put down, and where: in the streets, or in a building. + dropped: Vec<(Spot, Stack)>, + /// The menu `esc` opens. + pause: Option<Pause>, +} + +/// Somewhere in the city: the building it's in, if it's indoors, and where +/// in it. +type Spot = (Option<usize>, Pos); + +impl TownScene { + /// Walks out of your ship onto the landing field outside the capital of + /// `place`, a planet or moon where people live. + pub fn new(ctx: &Ctx, place: usize) -> TownScene { + let s = &ctx.system; + let (world, people, description) = match s.places[place].kind { + PlaceKind::Planet(p) => { + let planet = &s.planets[p]; + (&planet.name, planet.people, &planet.description) + } + PlaceKind::Moon(p, m) => { + let moon = &s.planets[p].moons[m]; + (&moon.name, moon.people, &moon.description) + } + PlaceKind::Belt | PlaceKind::Station(_) => (&s.places[place].name, None, &Vec::new()), + }; + let culture = people.unwrap_or(s.culture); + let size = town::size_for(survey::survey(s, place, ctx.day).population); + let land = Land::of(s, place, ctx.day).unwrap_or(Land::new(Terrain::Barren, None)); + let town = town::generate(town::seed_for(s.seed, place), culture, size, land); + let mut log = vec![format!( + "You walk down the ramp onto the landing field outside {}, the capital of {world}.", + town.name + )]; + log.extend(description.iter().cloned()); + log.push("The gate is east. Bump a door to go in; m shows the whole city.".into()); + TownScene { + player: town.start, + facing: (1, 0), + place, + world: world.clone(), + said: log.len(), + log, + trading: None, + map: false, + visited: HashSet::new(), + rumours: trade::rumours( + s, + place, + ctx.day, + &ctx.stripped, + ctx.ticks, + trade::lane_rumours(&ctx.galaxy, ctx.star), + ), + heard: 0, + rng: seed::rng(seed::derive(s.seed, "townsfolk", ctx.ticks)), + inside: None, + town, + inventory: None, + shopping: None, + dropped: Vec::new(), + pause: None, + } + } + + /// Where you're standing. + fn here(&self) -> Spot { + (self.inside.as_ref().map(|i| i.building), self.player) + } + + fn floor(&self) -> Beside { + Beside::Floor(if self.inside.is_some() { "on the floor" } else { "on the ground" }) + } + + /// `g`: picks up what's where you stand, or lists it if there's more + /// than one thing. + fn pick_up(&mut self, ctx: &mut Ctx) { + match &inventory::lying(&self.dropped, self.here())[..] { + [] => self.say("There's nothing here to pick up.".into()), + [stack] => { + let text = self.carry_out(inventory::Command::Fetch(0, stack.count), ctx); + self.say(text); + } + _ => self.inventory = Some(Inventory::beside()), + } + } + + fn carry_out(&mut self, command: inventory::Command, ctx: &mut Ctx) -> String { + let (here, beside) = (self.here(), self.floor()); + let mut floor = inventory::gather(&mut self.dropped, here); + let text = + inventory::carry_out(command, &mut ctx.hold.gear, None, &mut floor, Some(beside)); + inventory::scatter(&mut self.dropped, here, floor); + text + } + + /// Steps up to a shop's counter. An armourer's rack opens beside what + /// you carry, to buy from and sell to; every other shop has a counter. + fn open_shop(&mut self, shop: usize) { + let s = &self.town.shops[shop]; + if s.kind == ShopKind::Armoury { + let mut inv = Inventory::beside(); + inv.note = Some(format!( + "{}: “{} {}”", + s.keeper, + salutation(self.town.culture), + pitch(s.kind) + )); + self.inventory = Some(inv); + self.shopping = Some(shop); + } else { + self.trading = Some(Trading { shop, choice: 0, reply: None }); + } + } + + /// What's on this city's armourer's rack this fortnight, as you left + /// it: the weapons, then the armour. + fn rack(&self, ctx: &Ctx) -> Vec<Stack> { + let fortnight = trade::fortnight(ctx.day); + let kept = ctx.racks.iter().find(|r| { + r.system == ctx.system.seed && r.place == self.place && r.fortnight == fortnight + }); + let (weapons, armour) = match kept { + Some(r) => (r.weapons.clone(), r.armour.clone()), + None => ( + trade::stock(&ctx.system, self.place, self.town.culture, fortnight), + trade::stock_armour(&ctx.system, self.place, self.town.culture, fortnight), + ), + }; + let weapons = weapons.into_iter().map(|w| Stack::one(Item::Weapon(w))); + weapons.chain(armour.into_iter().map(|a| Stack::one(Item::Armor(a)))).collect() + } + + fn inventory_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { + let floor = inventory::lying(&self.dropped, self.here()); + let rack = if self.shopping.is_some() { self.rack(ctx) } else { Vec::new() }; + let view = match self.shopping { + Some(_) => shop_view(ctx, self.place, &rack), + None => view(ctx, self.floor(), &floor), + }; + let Some(inv) = &mut self.inventory else { return }; + match inv.key(key, &view) { + Reply::Stay => {} + Reply::Close => { + self.inventory = None; + self.shopping = None; + } + Reply::Sort(sort) => ctx.inventory_sort = sort, + Reply::Do(command) => { + let text = match command { + Command::Buy(i) => self.buy(i, ctx), + Command::Sell(spot) => self.sell(Some(spot), None, ctx), + Command::SellAboard(i) => self.sell(None, Some(i), ctx), + Command::Mend(spot) => self.mend(Some(spot), None, ctx), + Command::MendAboard(i) => self.mend(None, Some(i), ctx), + Command::MendAll => self.mend(None, None, ctx), + _ => self.carry_out(command, ctx), + }; + if let Some(inv) = &mut self.inventory { + inv.note = Some(text.clone()); + } + self.say(text); + } + } + } + + /// Buys the `i`th thing on the armourer's rack (as `rack` lists it), if + /// you can pay. + fn buy(&mut self, i: usize, ctx: &mut Ctx) -> String { + let terms = trade::armoury_terms(&ctx.system, self.place); + let rack = trade::rack(&mut ctx.racks, &ctx.system, self.place, self.town.culture, ctx.day); + let armour = i.checked_sub(rack.weapons.len()); + let item = match armour { + None => Item::Weapon(rack.weapons[i].clone()), + Some(j) => match rack.armour.get(j) { + Some(a) => Item::Armor(a.clone()), + None => return "That's gone already.".into(), + }, + }; + let price = terms.asking(&item).unwrap_or(0); + let h = &mut ctx.hold; + if h.scrip < price { + return format!("That's {price} ¤, and you've {} ¤. Come back richer.", h.scrip); + } + h.scrip -= price; + match armour { + None => drop(rack.weapons.remove(i)), + Some(j) => drop(rack.armour.remove(j)), + } + let said = match &item { + Item::Weapon(w) if w.kit => format!("You buy back {} for {price} ¤.", w.name()), + Item::Weapon(w) => format!("{} is yours, for {price} ¤.", capitalise(&the(w))), + Item::Armor(a) => format!("{} {} yours, for {price} ¤.", capitalise(&a.the()), a.is()), + _ => String::new(), + }; + if h.deliver(Stack::one(item)) { + said + } else { + format!("{said} Your pack's full, so it'll be sent to your ship.") + } + } + + /// Sells a weapon or a piece of armour of yours to the armourer: one + /// you carry or wear (`carried`) or one in your ship's lockers + /// (`aboard`). It goes on the rack, to be bought back until the rack's + /// restocked. + fn sell( + &mut self, + carried: Option<items::Spot>, + aboard: Option<usize>, + ctx: &mut Ctx, + ) -> String { + let terms = trade::armoury_terms(&ctx.system, self.place); + let h = &mut ctx.hold; + let dealt = |s: Option<Stack>| s.filter(|s| terms.offer(&s.item).is_some()); + let stack = match (carried, aboard) { + (Some(spot), _) => { + if dealt(h.gear.get(spot)).is_none() { + return "I only deal in arms and armour.".into(); + } + match h.gear.remove(spot, 1) { + Ok(stack) => stack, + Err(why) => return why, + } + } + (None, Some(i)) => { + if dealt(h.lockers.get(i).cloned()).is_none() { + return "You've nothing like that aboard.".into(); + } + h.take_kept(Kept::Lockers(i), 1) + } + (None, None) => return String::new(), + }; + let price = terms.offer(&stack.item).unwrap_or(0); + let what = match &stack.item { + Item::Weapon(w) => the(w), + Item::Armor(a) => a.the(), + _ => return String::new(), + }; + let paid = h.earn(price); + let said = + format!("{price} ¤ for {what}. It'll find a new owner.{}", trade::debt_note(paid, h)); + let rack = trade::rack(&mut ctx.racks, &ctx.system, self.place, self.town.culture, ctx.day); + match stack.item { + Item::Weapon(w) => rack.weapons.push(w), + Item::Armor(a) => rack.armour.push(a), + _ => {} + } + said + } + + /// Has the armourer mend a piece of your armour whole: one you carry or + /// wear (`carried`), one in your ship's lockers (`aboard`), or with + /// neither, every piece that needs it, all at once. + fn mend( + &mut self, + carried: Option<items::Spot>, + aboard: Option<usize>, + ctx: &mut Ctx, + ) -> String { + let terms = trade::armoury_terms(&ctx.system, self.place); + let h = &mut ctx.hold; + let mut pieces: Vec<&mut Armor> = match (carried, aboard) { + (Some(items::Spot::Worn(slot)), _) => h.gear.worn.get_mut(&slot).into_iter().collect(), + (Some(items::Spot::Pack(i)), _) => { + armour_in(h.gear.pack.get_mut(i)).into_iter().collect() + } + (Some(_), _) => Vec::new(), + (None, Some(i)) => armour_in(h.lockers.get_mut(i)).into_iter().collect(), + (None, None) => { + let aboard = h.lockers.iter_mut().filter_map(|s| armour_in(Some(s))); + h.gear.armour_mut().chain(aboard).filter(|a| terms.mending(a) > 0).collect() + } + }; + let all = carried.is_none() && aboard.is_none(); + let cost: u32 = pieces.iter().map(|a| terms.mending(a)).sum(); + match pieces.as_slice() { + [] if all => return "Nothing of yours needs mending.".into(), + [] => return "That's not armour.".into(), + [a] if cost == 0 => { + return format!("{} {} whole already.", capitalise(&a.yours()), a.is()); + } + _ => {} + } + if h.scrip < cost { + let what = if pieces.len() > 1 { format!("all {}", pieces.len()) } else { "it".into() }; + return format!("That's {cost} ¤ to mend {what}, and you've {} ¤.", h.scrip); + } + h.scrip -= cost; + for a in &mut pieces { + a.repair(); + } + match pieces.as_slice() { + [a] => format!("{} {} whole again, for {cost} ¤.", capitalise(&a.yours()), a.is()), + _ => format!("All {} pieces are whole again, for {cost} ¤.", pieces.len()), + } + } + + fn say(&mut self, text: String) { + self.log.push(text); + self.said += 1; + if self.log.len() > 50 { + self.log.remove(0); + } + } + + /// Where you are: the streets, or the inside of a building. + fn site(&self) -> &Site { + self.inside.as_ref().map_or(&self.town.site, |i| &i.site) + } + + fn site_mut(&mut self) -> &mut Site { + match &mut self.inside { + Some(inside) => &mut inside.site, + None => &mut self.town.site, + } + } + + /// One step, and what's lying where you end up. + fn step(&mut self, dx: i32, dy: i32) -> Transition { + let before = self.here(); + let transition = self.stride(dx, dy); + if self.here() != before + && let Some(text) = inventory::underfoot(&inventory::lying(&self.dropped, self.here())) + { + self.say(text); + } + transition + } + + fn stride(&mut self, dx: i32, dy: i32) -> Transition { + self.facing = (dx, dy); + let from = self.player; + let to = from.offset(dx, dy); + if let Some(who) = self.site().person_at(to) { + let person = &self.site().people[who]; + if let Role::Keeper(shop) = person.role { + self.open_shop(shop); + return Transition::None; + } + if person.stays { + let text = self.talk(person); + self.say(text); + return Transition::None; + } + // Someone walking about: they step aside for you, onto where + // you were, if they can. + if self.site().tile(to).walkable() { + self.player = to; + self.site_mut().make_way(who, from); + } + return Transition::None; + } + if self.inside.is_some() { self.step_inside(to) } else { self.step_outside(to) } + } + + fn step_outside(&mut self, to: Pos) -> Transition { + let t = &self.town; + if !t.site.contains(to) { + if t.tile(self.player) == Tile::Street { + self.say(words::beyond(t.land.terrain).into()); + } + return Transition::None; + } + match t.tile(to) { + Tile::Ship => return Transition::Pop, + Tile::Door { open } => { + let Some(b) = t.door_at(to) else { return Transition::None }; + if open { + self.go_in(b); + } else { + let text = barred(t.buildings[b].kind); + self.say(text.into()); + } + } + tile if tile.walkable() => self.player = to, + tile => { + let text = match tile { + Tile::Wild => Some(words::wild_about(t.land.terrain, t.wild_sort(to))), + _ => words::tile_about(tile, t.culture, t.land), + }; + if let Some(text) = text { + self.say(text.into()); + } + } + } + Transition::None + } + + fn step_inside(&mut self, to: Pos) -> Transition { + let Some(inside) = &self.inside else { return Transition::None }; + if let Some(shop) = inside.shop_at(to) { + self.open_shop(shop); + return Transition::None; + } + match inside.site.tile(to) { + Tile::Exit => self.go_out(), + tile if tile.walkable() => self.player = to, + Tile::Furniture(thing) => { + self.say(words::thing_about(thing, inside.culture).into()); + } + Tile::Shelf => self.say("The keeper's wares, out of reach behind the counter.".into()), + _ => {} + } + Transition::None + } + + fn go_in(&mut self, b: usize) { + let inside = self.town.enter(b); + self.player = inside.start; + self.visited.insert(b); + let building = &self.town.buildings[b]; + let mut text = format!("You step into {}.", building.name); + let culture = self.town.culture; + let named = |role: Role| { + inside.site.people.iter().find(|p| p.role == role).and_then(|p| p.name.clone()) + }; + match building.kind { + Kind::Shop(ShopKind::Tavern) => { + let keeper = &self.town.shops[building.shop.unwrap_or(0)].keeper; + text += &format!(" It's warm and loud. {keeper} is behind the bar."); + } + Kind::Shop(_) => { + let keeper = &self.town.shops[building.shop.unwrap_or(0)].keeper; + text += &format!(" {keeper} is behind the counter."); + } + Kind::Palace => { + if let Some(name) = named(Role::Ruler) { + let title = words::ruler_title(culture); + text += &format!(" At the far end, {name}, {title}, sits on the throne."); + } + } + Kind::Temple | Kind::Shrine => text += " It is hushed, and smells of old smoke.", + Kind::House | Kind::Tenement | Kind::Manor => { + let home = inside.site.people.len(); + text += match home { + 0 => " Nobody's home.", + 1 => " Someone looks up as you come in.", + _ => " The household looks up as you come in.", + }; + } + _ => {} + } + self.inside = Some(inside); + self.say(text); + } + + fn go_out(&mut self) { + let Some(inside) = self.inside.take() else { return }; + let b = &self.town.buildings[inside.building]; + self.player = b.front(); + self.facing = b.facing.step(); + // Someone may have wandered onto the doorstep. + if let Some(who) = self.town.site.person_at(self.player) { + let away = self.player.offset(self.facing.0, self.facing.1); + self.town.site.make_way(who, away); + } + let quarter = self.town.quarter_at(self.player).map_or("the street", |q| q.name.as_str()); + let text = format!("You step back out into {quarter}."); + self.say(text); + } + + /// What `person` says, with who they are. + fn talk(&self, person: &Person) -> String { + let culture = self.town.culture; + match (&person.name, person.role) { + (_, Role::Stallholder) => format!("A stallholder calls out: “{}”", person.says), + (Some(name), Role::Ruler) => { + format!("{name}, {}: “{}”", words::ruler_title(culture), person.says) + } + (Some(name), _) => format!("{name}: “{}”", person.says), + (None, role) => format!("{}: “{}”", words::role_name(role, culture), person.says), + } + } + + /// Talks to someone beside you: whoever you're facing, or else anyone. + fn talk_nearby(&mut self) { + let site = self.site(); + let ahead = self.player.offset(self.facing.0, self.facing.1); + let who = site + .person_at(ahead) + .or_else(|| self.player.neighbours().find_map(|p| site.person_at(p))); + let Some(who) = who else { + self.say("There's nobody close enough to talk to.".into()); + return; + }; + let person = &self.site().people[who]; + let (role, pos) = (person.role, person.pos); + if let Role::Keeper(shop) = role { + self.open_shop(shop); + return; + } + let directions = + if self.inside.is_none() && role == Role::Passerby && self.rng.random_bool(0.4) { + self.directions(pos) + } else { + None + }; + let text = directions.unwrap_or_else(|| self.talk(&self.site().people[who])); + self.say(text); + } + + /// A passer-by tells you the way to a shop, one you haven't been into + /// if there is one. + fn directions(&mut self, from: Pos) -> Option<String> { + let t = &self.town; + let unseen: Vec<ShopKind> = ShopKind::ALL + .into_iter() + .filter(|&k| { + t.nearest_shop(k, from).is_some_and(|s| !self.visited.contains(&s.building)) + }) + .collect(); + let kind = *unseen.choose(&mut self.rng).or_else(|| ShopKind::ALL.choose(&mut self.rng))?; + let shop = t.nearest_shop(kind, from)?; + let to = t.buildings[shop.building].front(); + let (dx, dy) = (to.x - from.x, to.y - from.y); + let way = compass(dx, dy); + let here = t.quarter_at(from).map(|q| q.name.as_str()); + let there = t.quarter_at(to).map_or("the city", |q| q.name.as_str()); + let far = f64::from(town::distance2(to, from)).sqrt(); + let text = if far < 14.0 { + format!("A passer-by: “{}? It's just {way} of here.”", capitalise(&shop.name)) + } else if here == Some(there) { + format!( + "A passer-by: “Looking for {}? Try {}, {way} of here.”", + kind.trade(), + shop.name + ) + } else { + format!( + "A passer-by: “Looking for {}? Try {}, {way} of here, in {there}.”", + kind.trade(), + shop.name + ) + }; + Some(text) + } + + /// Steps until something changes: a turning, a door, someone in the + /// way, something said. + fn run(&mut self, dx: i32, dy: i32) -> Transition { + let mut around = self.around(dx, dy); + for _ in 0..RUN_LIMIT { + let (before, indoors, said) = (self.player, self.inside.is_some(), self.said); + let transition = self.step(dx, dy); + if !matches!(transition, Transition::None) { + return transition; + } + let stopped = self.player == before + || self.inside.is_some() != indoors + || self.trading.is_some() + || self.said != said; + let now = self.around(dx, dy); + if stopped || now != around { + break; + } + around = now; + } + Transition::None + } + + /// Which cells beside you are open, as you run: one and two to either + /// side for a straight run, all eight round you for a diagonal one. + fn around(&self, dx: i32, dy: i32) -> u8 { + let site = self.site(); + let open = |p: Pos| site.tile(p).walkable() || matches!(site.tile(p), Tile::Door { .. }); + let cells: Vec<Pos> = if dx != 0 && dy != 0 { + self.player.neighbours().collect() + } else { + let (sx, sy) = (-dy, dx); + [1, 2] + .into_iter() + .flat_map(|k| { + [self.player.offset(sx * k, sy * k), self.player.offset(-sx * k, -sy * k)] + }) + .collect() + }; + cells.into_iter().enumerate().fold(0, |bits, (i, p)| bits | (u8::from(open(p)) << i)) + } + + /// Esc: out to the street if you're indoors, or back to your ship. + fn leave(&mut self) -> Transition { + if self.inside.is_some() { + self.go_out(); + Transition::None + } else { + Transition::Pop + } + } + + fn offers(&self, shop: &Shop, ctx: &Ctx) -> Vec<Offer> { + let s = &ctx.system; + let h = &ctx.hold; + let mut offers = Vec::new(); + let mut offer = |text: String, deal| offers.push(Offer { text, deal }); + match shop.kind { + ShopKind::Broker => { + let tons = h.ore_tons(); + if tons > 0 { + let prices = trade::ore_prices(s, self.place); + let worth = ore_worth(h, &prices); + offer(format!("Sell all your ore, {tons} t, for {worth} ¤"), Deal::SellOre); + } + let salvage = trade::salvage_kept(h, s, self.place); + for &(at, worth) in &salvage { + let Some(stack) = h.kept().find(|&(k, _)| k == at).map(|(_, s)| s) else { + continue; + }; + let text = format!("Sell {}: {worth} ¤{}", stack.name(), at.note()); + offer(text, Deal::SellSalvage(at)); + } + if salvage.len() > 1 { + let worth: u32 = salvage.iter().map(|(_, w)| w).sum(); + offer(format!("Sell all your salvage for {worth} ¤"), Deal::SellAllSalvage); + } + } + ShopKind::Doctor => { + let price = trade::medpatch_price(s, self.place); + offer(format!("Buy a medpatch: {price} ¤"), Deal::BuyMedpatch); + } + ShopKind::Scholar => { + let fee = trade::identify_fee(s, self.place); + for (at, stack) in h.kept() { + let Item::Artifact(a) = &stack.item else { continue }; + let note = at.note(); + if a.identified { + let pay = trade::artifact_offer(s, self.place, a); + offer( + format!("Sell {}: {pay} ¤{note}", a.true_name), + Deal::SellArtifact(at), + ); + } else { + offer( + format!("Have {} studied: {fee} ¤{note}", a.unidentified), + Deal::Identify(at), + ); + } + } + } + ShopKind::Tavern => { + offer(format!("Buy a round, and listen: {} ¤", trade::ROUND_PRICE), Deal::BuyRound); + } + // The armourer's rack opens in the inventory instead (`open_shop`). + ShopKind::Armoury => {} + ShopKind::Shipyard => { + let room = h.fuel_capacity.saturating_sub(h.fuel); + if room > 0 { + let price = trade::fuel_price(s, self.place); + let s = if room == 1 { "" } else { "s" }; + offer( + format!("Fill your tank, {room} jump{s} of fuel: {} ¤", room * price), + Deal::BuyFuel, + ); + } + if let Some(cost) = trade::rack_cost(h) { + let more = h.ore_capacity + trade::RACK_TONS; + offer( + format!("Cargo racks, to hold {more} t of ore: {cost} ¤"), + Deal::BuyRacks, + ); + } + if let Some(cost) = trade::lens_cost(ctx.lenses) { + offer(format!("A finer laser lens, to cut faster: {cost} ¤"), Deal::BuyLens); + } + if let Some(cost) = trade::tank_cost(h) { + let more = h.fuel_capacity + trade::TANK_JUMPS; + offer(format!("An extra fuel tank, for {more} jumps: {cost} ¤"), Deal::BuyTank); + } + if let Some((name, more, cost)) = items::next_pack(h.gear.capacity) { + let kg = items::kg(h.gear.capacity + more); + offer(format!("A {name}, to carry {kg} kg: {cost} ¤"), Deal::BuyPack); + } + } + } + offers + } + + /// Does the deal if you can, and says how it went. + fn deal(&mut self, deal: Deal, ctx: &mut Ctx) -> String { + let s = &ctx.system; + let h = &mut ctx.hold; + let place = self.place; + let pay = |h: &mut Hold, cost: u32| { + let ok = h.scrip >= cost; + if ok { + h.scrip -= cost; + } + ok + }; + let broke = || "You can't afford that.".to_string(); + match deal { + Deal::SellOre => { + let debt = h.debt; + match trade::sell_ore(h, &trade::ore_prices(s, place)) { + Some((tons, earned)) => format!( + "Sold {tons} t of ore for {earned} ¤. A pleasure.{}", + trade::debt_note(debt - h.debt, h) + ), + None => "You've no ore to sell.".into(), + } + } + Deal::SellSalvage(at) => { + let debt = h.debt; + match trade::sell_salvage(h, s, place, at) { + Some((what, earned)) => format!( + "{earned} ¤ for {what}. Good scrap.{}", + trade::debt_note(debt - h.debt, h) + ), + None => "You've no salvage to sell.".into(), + } + } + Deal::SellAllSalvage => { + let debt = h.debt; + match trade::sell_all_salvage(h, s, place) { + Some((pieces, earned)) => format!( + "{earned} ¤ for the lot, {pieces} pieces. Good scrap.{}", + trade::debt_note(debt - h.debt, h) + ), + None => "You've no salvage to sell.".into(), + } + } + Deal::BuyMedpatch => { + if h.medpatches() >= trade::MEDPATCH_LIMIT { + format!( + "You've {} already. I won't sell you more than {}.", + h.medpatches(), + trade::MEDPATCH_LIMIT + ) + } else if pay(h, trade::medpatch_price(s, place)) { + if h.deliver(Stack::one(Item::Medpatch)) { + format!("Here. Don't waste it. (You have {}.)", h.medpatches()) + } else { + "Here. Your pack's full, so I'll have it sent to your ship.".into() + } + } else { + broke() + } + } + Deal::Identify(at) => { + if !matches!(h.kept_mut(at), Some(Stack { item: Item::Artifact(_), .. })) { + "You've nothing like that to show me.".into() + } else if pay(h, trade::identify_fee(s, place)) { + let Some(Stack { item: Item::Artifact(a), .. }) = h.kept_mut(at) else { + return String::new(); + }; + a.identified = true; + let offer = trade::artifact_offer(s, place, a); + format!( + "Ah. Your {} is {}. I'd give you {offer} ¤ for it.", + a.unidentified.trim_start_matches("a ").trim_start_matches("an "), + a.true_name + ) + } else { + broke() + } + } + Deal::SellArtifact(at) => { + if !matches!(h.kept_mut(at), Some(Stack { item: Item::Artifact(_), .. })) { + return "You've nothing like that to sell me.".into(); + } + let Item::Artifact(a) = h.take_kept(at, 1).item else { return String::new() }; + let offer = trade::artifact_offer(s, place, &a); + let paid = h.earn(offer); + format!( + "{offer} ¤, as promised. {} will be well kept.{}", + capitalise(&a.true_name), + trade::debt_note(paid, h) + ) + } + Deal::BuyPack => match items::next_pack(h.gear.capacity) { + Some((name, more, cost)) if pay(h, cost) => { + h.gear.capacity += more; + format!( + "One {name}, fitted to you. You can carry {} kg now.", + items::kg(h.gear.capacity) + ) + } + Some(_) => broke(), + None => "That's the biggest pack there is. Any more and you'd need a mule.".into(), + }, + Deal::BuyRound => { + if pay(h, trade::ROUND_PRICE) { + let rumour = &self.rumours[self.heard % self.rumours.len()]; + self.heard += 1; + format!("The room warms to you. Someone leans over: “{rumour}”") + } else { + broke() + } + } + Deal::BuyRacks => match trade::rack_cost(h) { + Some(cost) if pay(h, cost) => { + h.ore_capacity += trade::RACK_TONS; + format!("Racks fitted. Your hold takes {} t of ore now.", h.ore_capacity) + } + Some(_) => broke(), + None => "Your hold's as big as it gets.".into(), + }, + Deal::BuyFuel => match trade::buy_fuel(h, trade::fuel_price(s, place)) { + (0, _) if h.fuel >= h.fuel_capacity => "Your tank's full already.".into(), + (0, _) => broke(), + (jumps, cost) => format!( + "{jumps} jump{} of fuel for {cost} ¤, pumped aboard. Your tank holds {}/{}.", + if jumps == 1 { "" } else { "s" }, + h.fuel, + h.fuel_capacity + ), + }, + Deal::BuyTank => match trade::tank_cost(h) { + Some(cost) if pay(h, cost) => { + h.fuel_capacity += trade::TANK_JUMPS; + format!( + "The tank's welded in. You can carry {} jumps of fuel now; it's empty, mind.", + h.fuel_capacity + ) + } + Some(_) => broke(), + None => "There's no room aboard for another tank.".into(), + }, + Deal::BuyLens => match trade::lens_cost(ctx.lenses) { + Some(cost) if pay(h, cost) => { + ctx.lenses += 1; + "The new lens is ground and fitted. Your laser will cut faster.".into() + } + Some(_) => broke(), + None => "That's the finest lens there is.".into(), + }, + } + } + + fn trading_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { + let Some(trading) = &self.trading else { return }; + let shop = &self.town.shops[trading.shop]; + let offers = self.offers(shop, ctx); + let choice = trading.choice.min(offers.len().saturating_sub(1)); + let take = match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.trading = None; + return; + } + KeyCode::Up | KeyCode::Char('k') => { + self.set_choice(choice.saturating_sub(1)); + None + } + KeyCode::Down | KeyCode::Char('j') => { + self.set_choice((choice + 1).min(offers.len().saturating_sub(1))); + None + } + KeyCode::Enter | KeyCode::Char(' ') => Some(choice), + KeyCode::Char(c @ '1'..='9') => Some(c as usize - '1' as usize), + _ => None, + }; + if let Some(i) = take + && let Some(offer) = offers.get(i) + { + let reply = self.deal(offer.deal, ctx); + if let Some(trading) = &mut self.trading { + trading.reply = Some(reply); + trading.choice = i; + } + } + } + + fn set_choice(&mut self, choice: usize) { + if let Some(trading) = &mut self.trading { + trading.choice = choice; + } + } +} + +/// "north-east", for something `dx` across and `dy` down from you. +fn compass(dx: i32, dy: i32) -> &'static str { + const WAYS: [&str; 8] = + ["east", "north-east", "north", "north-west", "west", "south-west", "south", "south-east"]; + // Rows are twice as tall as columns are wide. + let angle = f64::from(-2 * dy).atan2(f64::from(dx)); + let sector = (angle / std::f64::consts::FRAC_PI_4).round() as i32; + WAYS[sector.rem_euclid(8) as usize] +} + +fn ore_worth(hold: &Hold, prices: &[(&'static crate::space::locale::Ore, u32)]) -> u32 { + prices.iter().map(|(ore, price)| hold.ore.get(ore.name).copied().unwrap_or(0) * price).sum() +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +impl Scene for TownScene { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + if self.trading.is_some() { + self.trading_key(key, ctx); + return Transition::None; + } + if self.inventory.is_some() { + self.inventory_key(key, ctx); + return Transition::None; + } + 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 => self.leave(), + Choice::Resume => Transition::None, + }; + } + if self.map { + if matches!(key.code, KeyCode::Esc | KeyCode::Tab | KeyCode::Char('m' | 'M' | 'q')) { + self.map = false; + } + return Transition::None; + } + match key.code { + KeyCode::Char('i') => { + self.inventory = Some(Inventory::new()); + return Transition::None; + } + KeyCode::Char('g' | ',') => { + self.pick_up(ctx); + return Transition::None; + } + KeyCode::Char('c' | 'C') => return Transition::Push(Box::new(Sheet::new())), + KeyCode::Char('m' | 'M') | KeyCode::Tab => { + self.map = true; + return Transition::None; + } + KeyCode::Char('t' | 'T') => { + self.talk_nearby(); + return Transition::None; + } + KeyCode::Esc => { + let here = if self.inside.is_some() { + "Back out to the street" + } else { + "Back to your ship" + }; + let note = + format!("In {}. Quit, and you'll come back aboard your ship.", self.town.name); + self.pause = Some(Pause::new(Some(here), Some(note))); + return Transition::None; + } + _ => {} + } + match keys::movement(key) { + Some((dx, dy, true)) => self.run(dx, dy), + Some((dx, dy, false)) => self.step(dx, dy), + None => Transition::None, + } + } + + fn tick(&mut self, _ctx: &mut Ctx) -> Transition { + let player = self.player; + let site = match &mut self.inside { + Some(inside) => &mut inside.site, + None => &mut self.town.site, + }; + for who in 0..site.people.len() { + if self.rng.random_range(0..WANDER_TICKS) == 0 { + site.wander(who, &mut self.rng, player); + } + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [main, panel] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) + .areas(frame.area()); + let [map, log] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); + self.draw_map(frame.buffer_mut(), map, ctx.ticks); + self.draw_log(frame, log); + self.draw_panel(frame, panel, ctx); + if self.map { + self.draw_city(frame, map); + } + if let Some(trading) = &self.trading { + self.draw_counter(frame, map, trading, ctx); + } + if let Some(inv) = &self.inventory { + let floor = inventory::lying(&self.dropped, self.here()); + let rack = if self.shopping.is_some() { self.rack(ctx) } else { Vec::new() }; + let view = match self.shopping { + Some(_) => shop_view(ctx, self.place, &rack), + None => view(ctx, self.floor(), &floor), + }; + inv.render(frame, frame.area(), &view); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); + } + } +} + +/// What the inventory's shown: what you carry, and what's at your feet. +fn view<'a>(ctx: &'a Ctx, beside: Beside, floor: &'a [Stack]) -> View<'a> { + View { + gear: &ctx.hold.gear, + beside: (!floor.is_empty()).then_some((beside, floor)), + known: &ctx.bestiary, + hurt: false, + shop: None, + sort: ctx.inventory_sort, + } +} + +/// What the inventory's shown at an armourer's: what you carry and what's +/// in your lockers, beside the `rack`, with the prices at `place`. +fn shop_view<'a>(ctx: &'a Ctx, place: usize, rack: &'a [Stack]) -> View<'a> { + View { + gear: &ctx.hold.gear, + beside: Some((Beside::Rack, rack)), + known: &ctx.bestiary, + hurt: false, + shop: Some(Counter { + terms: trade::armoury_terms(&ctx.system, place), + aboard: &ctx.hold.lockers, + scrip: ctx.hold.scrip, + }), + sort: ctx.inventory_sort, + } +} + +/// The armour in a stack, if that's what it is. +fn armour_in(stack: Option<&mut Stack>) -> Option<&mut Armor> { + match stack { + Some(Stack { item: Item::Armor(a), .. }) => Some(a), + _ => None, + } +} + +/// "the keen cleaver", "Khaseem's Mercy", "your cutting torch". +fn the(w: &Weapon) -> String { + if w.named || w.kit { w.name() } else { format!("the {}", w.title) } +} + +/// How someone looks. +fn person_look(role: Role, theme: &Theme) -> (char, Rgb, bool) { + match role { + Role::Keeper(_) => ('&', (255, 214, 92), true), + Role::Ruler => ('&', (255, 236, 130), true), + Role::Stallholder => ('&', (255, 214, 140), false), + Role::Guard => ('&', (214, 100, 90), false), + Role::Priest => ('&', (226, 226, 255), false), + Role::Child => ('&', (250, 240, 200), false), + _ => ('&', theme.folk, false), + } +} + +/// A fire's colour, flickering cell by cell. +fn flicker(p: Pos, ticks: u64) -> Rgb { + const FLAMES: [Rgb; 3] = [(255, 150, 60), (255, 110, 40), (255, 190, 90)]; + let k = (ticks / 5) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 13; + FLAMES[k.rem_euclid(3) as usize] +} + +pub(crate) fn thing_look(thing: Thing, theme: &Theme, p: Pos, ticks: u64) -> (char, Rgb) { + const WOOD: Rgb = (156, 114, 72); + const DARK_WOOD: Rgb = (124, 88, 56); + const IRON: Rgb = (150, 156, 172); + const STONE: Rgb = (180, 176, 170); + match thing { + Thing::Table => ('╤', WOOD), + Thing::Chair => ('╥', DARK_WOOD), + Thing::Bed => ('Θ', theme.bed), + Thing::Hearth | Thing::Forge => ('^', flicker(p, ticks)), + Thing::Stove => ('◘', IRON), + Thing::Shelf => ('≡', (176, 150, 110)), + Thing::Chest => ('▪', (190, 146, 70)), + Thing::Crate => ('■', (170, 140, 96)), + Thing::Barrel => ('ö', (150, 104, 64)), + Thing::Altar => theme.altar, + Thing::Pew | Thing::Bench => ('═', WOOD), + Thing::Pillar => ('●', STONE), + Thing::Statue => ('ß', STONE), + Thing::Throne => ('Ψ', (240, 200, 90)), + Thing::Plant => ('♣', (90, 170, 80)), + Thing::Bath => { + let shimmer = (ticks / 9) as i64 + i64::from(p.x) * 3 + i64::from(p.y) * 5; + ('≈', if shimmer.rem_euclid(4) == 0 { (150, 210, 240) } else { (90, 160, 220) }) + } + Thing::Anvil => ('π', IRON), + Thing::Loom => ('╫', WOOD), + Thing::Workbench => ('╦', WOOD), + Thing::Rack => ('/', IRON), + Thing::Lamp => ('¡', flicker(p, ticks)), + Thing::Engine => ('¤', (150, 172, 196)), + } +} + +/// A colour made `k` times as bright. +fn shade((r, g, b): Rgb, k: f64) -> Rgb { + let f = |c: u8| (f64::from(c) * k).round().clamp(0.0, 255.0) as u8; + (f(r), f(g), f(b)) +} + +/// How a building's roof looks at `p`: its colour, and the shop's sign or +/// the landmark's mark painted on it. +fn roof_look(t: &Town, b: usize, p: Pos, theme: &Theme) -> (char, Rgb, bool) { + let building = &t.buildings[b]; + let h = seed::derive(b as u64, "roof", 0); + let colour = + shade(theme.roofs[(h % 3) as usize], 0.85 + 0.25 * ((h >> 8) % 100) as f64 / 100.0); + let centre = building.centre(); + let grand = matches!(building.kind, Kind::Palace | Kind::Temple | Kind::Manor); + if let Some(shop) = building.shop { + let sign = t.shops[shop].kind.sign(); + let start = centre.x - sign.len() as i32 / 2; + if p.y == centre.y && p.x >= start && p.x < start + sign.len() as i32 { + let ch = sign.as_bytes()[(p.x - start) as usize] as char; + return (ch, SIGN, true); + } + } + if p == centre { + match building.kind { + Kind::Palace => return ('Ψ', SIGN, true), + Kind::Temple => return (theme.altar.0, SIGN, true), + _ => {} + } + } + (if grand { '▓' } else { '▒' }, colour, false) +} + +/// How a pond looks today. +fn pond_look(theme: &Theme, p: Pos, ticks: u64) -> (char, Rgb) { + match theme.world.pond { + Pond::Water => ('≈', (80, 140, 210)), + Pond::Ice => ('░', (172, 210, 240)), + Pond::Dry => ('~', (150, 126, 94)), + Pond::Lava => ('≈', flicker(p, ticks)), + } +} + +/// How a cell of the streets looks, leaving out people. +pub(crate) fn street_look(t: &Town, p: Pos, theme: &Theme, ticks: u64) -> (char, Rgb, bool) { + let tile = t.tile(p); + let (glyph, colour) = match tile { + Tile::Ground => theme.world.ground, + Tile::Pad => ('·', (78, 82, 96)), + Tile::Ship => { + let (dx, dy) = (p.x - t.ship.x, p.y - t.ship.y); + let ch = SHIP_ART[dy as usize].as_bytes()[dx as usize] as char; + (ch, (206, 212, 228)) + } + Tile::CityWall => theme.city_wall, + Tile::Gate | Tile::Street => theme.street, + Tile::Square => theme.square, + Tile::Garden => theme.world.garden, + Tile::Water => pond_look(theme, p, ticks), + Tile::Sea => { + let swell = (ticks / 12) as i64 + i64::from(p.x) * 5 + i64::from(p.y) * 11; + let crest = swell.rem_euclid(7) == 0; + ('≈', theme.world.sea[usize::from(crest)]) + } + Tile::Decor => theme.decor.unwrap_or(theme.world.tree), + Tile::Tree => theme.world.tree, + Tile::Wild => match t.wild_sort(p) { + 1 if theme.world.vents => ('^', flicker(p, ticks)), + sort => theme.world.wild[sort], + }, + Tile::Centrepiece => theme.centrepiece, + Tile::Stall => theme.stall, + Tile::Statue => ('ß', (200, 196, 190)), + Tile::Well => ('o', (110, 170, 230)), + Tile::Lamp => ('¡', flicker(p, ticks)), + Tile::Roof => match t.building_at(p) { + Some(b) => return roof_look(t, b, p, theme), + None => ('▒', theme.roofs[0]), + }, + Tile::Door { open: true } => ('+', (236, 196, 120)), + Tile::Door { open: false } => ('+', (110, 88, 66)), + Tile::Wall | Tile::Floor | Tile::Doorway | Tile::Exit | Tile::Counter | Tile::Shelf => { + theme.floor + } + Tile::Rug => ('░', theme.rug), + Tile::Furniture(thing) => thing_look(thing, theme, p, ticks), + }; + (glyph, colour, false) +} + +/// How a cell inside a building looks, leaving out people. +pub(crate) fn inside_look( + t: &Town, + inside: &Interior, + p: Pos, + theme: &Theme, + ticks: u64, +) -> (char, Rgb, bool) { + let floor = match inside.kind { + Kind::Palace | Kind::Temple | Kind::Manor => ('·', (178, 172, 188)), + Kind::Shop(ShopKind::Tavern) | Kind::Workshop => ('.', (146, 112, 80)), + Kind::Warehouse | Kind::Guardhouse => ('.', (110, 108, 104)), + _ => theme.floor, + }; + let (glyph, colour) = match inside.site.tile(p) { + Tile::Wall => theme.wall, + Tile::Floor => floor, + Tile::Doorway => ('\'', theme.wall.1), + Tile::Exit => ('+', (236, 196, 120)), + Tile::Counter => ('=', (190, 156, 112)), + Tile::Shelf => { + let shop = inside.shop.map(|s| t.shops[s].kind); + shop.map_or(('"', (150, 140, 120)), shelf) + } + Tile::Rug => ('░', theme.rug), + Tile::Furniture(thing) => thing_look(thing, theme, p, ticks), + _ => return street_look(t, p, theme, ticks), + }; + (glyph, colour, false) +} + +impl TownScene { + fn draw_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) { + let site = self.site(); + let left = camera(self.player.x, site.width, i32::from(area.width)); + let top = camera(self.player.y, site.height, i32::from(area.height)); + let theme = theme(&self.town); + let folk: HashMap<Pos, &Person> = site.people.iter().map(|p| (p.pos, p)).collect(); + let building = self.inside.as_ref().map(|i| i.building); + // What's been put down, the last thing on top. + let things: HashMap<Pos, &Stack> = self + .dropped + .iter() + .filter(|((b, _), _)| *b == building) + .map(|((_, p), s)| (*p, s)) + .collect(); + for sy in 0..area.height { + for sx in 0..area.width { + let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); + let (glyph, colour, bold) = if !site.contains(p) { + (' ', (0, 0, 0), false) + } else if p == self.player { + ('@', (255, 255, 255), true) + } else if let Some(person) = folk.get(&p) { + person_look(person.role, &theme) + } else if let Some(stack) = things.get(&p) { + let (glyph, colour) = inventory::look(&stack.item); + (glyph, colour, false) + } else if let Some(inside) = &self.inside { + inside_look(&self.town, inside, p, &theme, ticks) + } else { + street_look(&self.town, p, &theme, ticks) + }; + let cell = &mut buf[(area.x + sx, area.y + sy)]; + cell.set_char(glyph).set_fg(rgb(colour)); + if bold { + cell.modifier.insert(Modifier::BOLD); + } + } + } + } + + /// The whole city, shrunk to fit: each cell of it stands for a square + /// of streets, with the shops and landmarks marked. + fn draw_city(&self, frame: &mut Frame, area: Rect) { + let t = &self.town; + let theme = theme(t); + let mark = |glyph: char, what: &'static str| { + vec![ + Span::raw(format!(" {glyph}")).fg(rgb(SIGN)).bold(), + Span::raw(format!(" {what}")).fg(DIM), + ] + }; + let mut legend = vec![Span::raw(" @").white().bold(), Span::raw(" you").fg(DIM)]; + // As many as fit along the bottom, whole. + let room = usize::from(area.width.saturating_sub(4)); + for (glyph, what) in [ + ('%', "ore"), + ('!', "doctor"), + ('≡', "scholar"), + ('o', "tavern"), + ('¤', "shipyard"), + (')', "arms"), + ('Ψ', "palace"), + (theme.altar.0, "temple"), + ] { + let used: usize = legend.iter().map(Span::width).sum(); + if used + 3 + what.chars().count() > room { + break; + } + legend.extend(mark(glyph, what)); + } + legend.push(Span::raw(" ").fg(DIM)); + let block = Block::bordered() + .border_style(Style::new().fg(ACCENT)) + .title(Line::from(format!(" {} ", t.name)).bold().fg(ACCENT)) + .title(keys::line(&[("m esc", "close ")]).right_aligned()) + .title_bottom(Line::from(legend)); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + let (aw, ah) = (i32::from(inner.width), i32::from(inner.height)); + if aw <= 0 || ah <= 0 { + return; + } + let scale = ((t.width() + aw - 1) / aw).max((t.height() + ah - 1) / ah).max(1); + let (mw, mh) = ((t.width() + scale - 1) / scale, (t.height() + scale - 1) / scale); + let (ox, oy) = (i32::from(inner.x) + (aw - mw) / 2, i32::from(inner.y) + (ah - mh) / 2); + let buf = frame.buffer_mut(); + let mut put = |x: i32, y: i32, glyph: char, colour: Rgb, bold: bool| { + let (Ok(x), Ok(y)) = (u16::try_from(x), u16::try_from(y)) else { return }; + if !inner.contains((x, y).into()) { + return; + } + let cell = &mut buf[(x, y)]; + cell.set_char(glyph).set_fg(rgb(colour)); + if bold { + cell.modifier.insert(Modifier::BOLD); + } + }; + for cy in 0..mh { + for cx in 0..mw { + let (glyph, colour) = self.summary(cx * scale, cy * scale, scale, &theme); + put(ox + cx, oy + cy, glyph, colour, false); + } + } + // Shops and landmarks, your ship, and you, where they'll go on the map. + let mut marks: Vec<(i32, i32, char, Rgb)> = Vec::new(); + for b in &t.buildings { + let glyph = match b.kind { + Kind::Shop(kind) => shelf(kind).0, + Kind::Palace => 'Ψ', + Kind::Temple => theme.altar.0, + _ => continue, + }; + marks.push((ox + b.door.x / scale, oy + b.door.y / scale, glyph, SIGN)); + } + marks.push((ox + t.ship.x / scale, oy + t.ship.y / scale, '▲', (206, 212, 228))); + let you = self.inside.as_ref().map_or(self.player, |i| t.buildings[i.building].door); + marks.push((ox + you.x / scale, oy + you.y / scale, '@', (255, 255, 255))); + // Quarter names near the middle of each quarter, nudged up or down + // off the marks and each other, or left off if there's no room. + let mut taken: Vec<(i32, i32, i32)> = Vec::new(); + for q in &t.quarters { + let len = q.name.chars().count() as i32; + let x0 = ox + q.centre.x / scale - len / 2; + if x0 < i32::from(inner.x) || x0 + len > i32::from(inner.x) + aw { + continue; + } + let free = |y: i32| { + y >= i32::from(inner.y) + && y < i32::from(inner.y) + ah + && !taken.iter().any(|&(ty, a, b)| ty == y && x0 <= b + 1 && x0 + len >= a - 1) + && !marks + .iter() + .any(|&(mx, my, ..)| my == y && (x0 - 1..=x0 + len).contains(&mx)) + }; + let y = oy + q.centre.y / scale; + let Some(y) = [0, -1, 1, -2, 2].into_iter().map(|dy| y + dy).find(|&y| free(y)) else { + continue; + }; + taken.push((y, x0, x0 + len - 1)); + for (i, ch) in q.name.chars().enumerate() { + put(x0 + i as i32, y, ch, (200, 204, 220), false); + } + } + for (x, y, glyph, colour) in marks { + put(x, y, glyph, colour, true); + } + } + + /// One glyph for the `scale`-square of streets from `(x, y)`. + fn summary(&self, x: i32, y: i32, scale: i32, theme: &Theme) -> (char, Rgb) { + let t = &self.town; + // Walls count most, so the city's outline stays unbroken. + let mut weights = [0; 7]; + for dy in 0..scale { + for dx in 0..scale { + let class = match t.tile(Pos::new(x + dx, y + dy)) { + _ if !t.site.contains(Pos::new(x + dx, y + dy)) => continue, + Tile::CityWall => (0, 8), + Tile::Street | Tile::Gate | Tile::Lamp => (1, 2), + Tile::Square | Tile::Stall | Tile::Centrepiece | Tile::Well | Tile::Statue => { + (2, 2) + } + Tile::Roof | Tile::Door { .. } => (3, 1), + Tile::Garden | Tile::Decor | Tile::Tree => (4, 1), + Tile::Water => (5, 3), + Tile::Sea => (6, 2), + _ => continue, + }; + weights[class.0] += class.1; + } + } + let best = (0..weights.len()).max_by_key(|&i| (weights[i], usize::MAX - i)).unwrap_or(0); + if weights[best] == 0 { + return (theme.world.ground.0, shade(theme.world.ground.1, 0.6)); + } + match best { + 0 => theme.city_wall, + 1 => theme.street, + 2 => theme.square, + 3 => ('▒', theme.roofs[0]), + 4 => theme.world.garden, + 5 => pond_look(theme, Pos::new(x, y), 0), + _ => ('≈', theme.world.sea[0]), + } + } + + fn draw_log(&self, frame: &mut Frame, area: Rect) { + let shown = usize::from(area.height.saturating_sub(1)); + let first = self.log.len().saturating_sub(shown); + let lines: Vec<Line> = self.log[first..] + .iter() + .enumerate() + .map(|(i, text)| { + let age = self.log.len() - first - 1 - i; + let line = Line::from(text.as_str()); + match age { + 0 => line.white(), + 1 | 2 => line.gray(), + _ => line.dark_gray(), + } + }) + .collect(); + let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray()); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + } + + /// Where you are, for the panel: the quarter, or the building and room. + fn whereabouts(&self) -> Vec<String> { + let t = &self.town; + match &self.inside { + Some(inside) => { + let mut lines = vec![capitalise(&t.buildings[inside.building].name)]; + if inside.room_count() > 1 + && let Some(purpose) = inside.purpose_at(self.player) + { + lines.push(format!(" {}", words::room_name(purpose, t.culture))); + } + let (dx, dy) = (inside.exit.x - self.player.x, inside.exit.y - self.player.y); + if dx.abs() + dy.abs() > 1 { + lines.push(format!(" the way out is {}", compass(dx, dy))); + } + lines + } + None => { + let p = self.player; + let place = match t.tile(p) { + Tile::Pad => "the landing field".to_string(), + Tile::Ground => "outside the walls".to_string(), + _ if t.square.y == p.y && t.tile(p) == Tile::Square => { + "the market square".into() + } + _ => t.quarter_at(p).map_or("the streets".into(), |q| capitalise(&q.name)), + }; + vec![capitalise(&place)] + } + } + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let t = &self.town; + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(format!(" {} ", t.name)).bold().fg(ACCENT)) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let hints = keys::packed( + &[ + ("arrows hjkl yubn", "walk"), + ("shift", "run"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("t", "talk"), + ("m", "city map"), + ("bump", "go in, trade, talk"), + ("esc", "menu: leave or quit"), + ], + inner.width, + ); + let [info, _, help] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(hints.len() as u16), + ]) + .areas(inner); + + let h = &ctx.hold; + let label = |text: &'static str| Span::raw(text).fg(DIM); + let mut lines = vec![ + Line::from(format!("capital of {}", self.world)).fg(DIM), + Line::from(format!("a city of {}", t.culture.name())).fg(DIM), + Line::default(), + ]; + for line in self.whereabouts() { + lines.push(Line::from(line).fg(ACCENT)); + } + lines.extend([ + Line::default(), + Line::from(vec![label("money "), Span::raw(format!("{} ¤", h.scrip))]), + Line::from(vec![ + label("ore "), + Span::raw(format!("{}/{} t", h.ore_tons(), h.ore_capacity)), + ]), + Line::from(vec![ + label("fuel "), + Span::raw(format!("{}/{} jumps", h.fuel, h.fuel_capacity)), + ]), + Line::from(vec![label("lenses "), Span::raw(ctx.lenses.to_string())]), + Line::from(vec![ + label("carrying "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.gear.carried()), + items::kg(h.gear.capacity) + )), + ]), + ]); + if h.debt > 0 { + lines.push(Line::from(vec![label("owed "), Span::raw(format!("{} ¤", h.debt))])); + } + // What you might sell here, on you or aboard. + for (at, stack) in h.kept() { + let colour = match &stack.item { + Item::Artifact(a) if a.identified => ACCENT, + Item::Artifact(_) => Color::Yellow, + Item::Salvage(_) => Color::Rgb(150, 200, 222), + _ => continue, + }; + let (glyph, _) = inventory::look(&stack.item); + let text = format!("{}{}", stack.name(), at.note()); + for (i, row) in + hanging(&text, usize::from(info.width).saturating_sub(2)).into_iter().enumerate() + { + let bullet = if i == 0 { format!("{glyph} ") } else { " ".into() }; + lines.push(Line::from(format!("{bullet}{row}")).fg(colour)); + } + } + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + + frame.render_widget(Paragraph::new(hints), help); + } + + /// The shop counter: the keeper, what they'll do for you, and what they + /// said last. + fn draw_counter(&self, frame: &mut Frame, area: Rect, trading: &Trading, ctx: &Ctx) { + let shop = &self.town.shops[trading.shop]; + let offers = self.offers(shop, ctx); + let h = &ctx.hold; + let mut lines = vec![ + Line::from(format!("{} says:", shop.keeper)).fg(DIM), + Line::from(format!("“{} {}”", salutation(self.town.culture), pitch(shop.kind))), + Line::default(), + ]; + match shop.kind { + ShopKind::Broker => { + for (ore, price) in trade::ore_prices(&ctx.system, self.place) { + let have = h.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(rgb(ore.rgb)), + Span::raw(format!("{:<16}{:>4} ¤ a ton ", ore.name, price)), + Span::raw(have).fg(DIM), + ])); + } + if h.ore_tons() == 0 && offers.is_empty() { + lines.push(Line::from("You've no ore or salvage to sell.").fg(DIM)); + } + } + ShopKind::Doctor => lines.push( + Line::from(format!( + "You have {} medpatches, on you and aboard. I'll sell you up to {}.", + h.medpatches(), + trade::MEDPATCH_LIMIT + )) + .fg(DIM), + ), + ShopKind::Scholar if offers.is_empty() => { + lines.push(Line::from("You've no artifacts to show.").fg(DIM)); + } + ShopKind::Shipyard => { + lines.push( + Line::from(format!( + "Fuel is {} ¤ a jump. Your tank holds {} of {}.", + trade::fuel_price(&ctx.system, self.place), + h.fuel, + h.fuel_capacity + )) + .fg(DIM), + ); + if offers.is_empty() { + lines.push(Line::from("There's nothing more I can fit to your ship.").fg(DIM)); + } + } + _ => {} + } + if !offers.is_empty() { + lines.push(Line::default()); + } + let choice = trading.choice.min(offers.len().saturating_sub(1)); + for (i, offer) in offers.iter().enumerate() { + let number = if i < 9 { format!("{} ", i + 1) } else { " ".into() }; + let mut line = + Line::from(vec![Span::raw(number).fg(ACCENT), Span::raw(offer.text.clone())]); + if i == choice { + line = line.reversed(); + } + lines.push(line); + } + if let Some(reply) = &trading.reply { + lines.push(Line::default()); + lines.push(Line::from(reply.clone()).fg(ACCENT)); + } + lines.push(Line::default()); + lines.push(keys::line(&[("1-9 ↑↓ enter", "choose"), ("esc", "step away")])); + + let [area] = Layout::horizontal([Constraint::Max(66)]).flex(Flex::Center).areas(area); + 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); + let block = Block::bordered() + .title(Line::from(format!(" {} ", shop.name)).bold().fg(ACCENT)) + .border_style(Style::new().fg(ACCENT)) + .padding(Padding::horizontal(1)); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + } +} + +/// Splits `text` into rows at most `width` wide, breaking between words, so +/// a list item's later rows can be indented under its first. +fn hanging(text: &str, width: usize) -> Vec<String> { + let mut rows: Vec<String> = Vec::new(); + for word in text.split_whitespace() { + match rows.last_mut() { + Some(row) if row.chars().count() + 1 + word.chars().count() <= width => { + row.push(' '); + row.push_str(word); + } + _ => rows.push(word.to_string()), + } + } + rows +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::names; + use crate::scenes::tests::test_ctx; + + fn press(scene: &mut TownScene, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn key_towards(dx: i32, dy: i32) -> KeyCode { + match (dx, dy) { + (-1, 0) => KeyCode::Left, + (1, 0) => KeyCode::Right, + (0, -1) => KeyCode::Up, + (0, 1) => KeyCode::Down, + (-1, -1) => KeyCode::Home, + (1, -1) => KeyCode::PageUp, + (-1, 1) => KeyCode::End, + _ => KeyCode::PageDown, + } + } + + /// The capital of the homeworld. + fn landed() -> (TownScene, Ctx) { + let ctx = test_ctx(); + let scene = TownScene::new(&ctx, ctx.system.home_place()); + (scene, ctx) + } + + /// Standing on the doorstep of building `b`, and stepping in. + fn walk_in(scene: &mut TownScene, ctx: &mut Ctx, b: usize) { + let building = &scene.town.buildings[b]; + scene.player = building.front(); + let (dx, dy) = building.facing.step(); + // Clear the doorstep of anyone wandering by. + scene.town.site.people.retain(|p| p.pos != scene.player); + press(scene, ctx, key_towards(-dx, -dy)); + } + + /// A town on the homeworld, standing at the counter of its `kind` of shop. + fn at_counter(kind: ShopKind) -> (TownScene, Ctx) { + let (mut scene, ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == kind).unwrap(); + scene.trading = Some(Trading { shop, choice: 0, reply: None }); + (scene, ctx) + } + + fn reply(scene: &TownScene) -> &str { + scene.trading.as_ref().unwrap().reply.as_deref().unwrap() + } + + #[test] + fn an_armourer_buys_and_sells_and_keeps_the_rack_for_the_fortnight() { + let (mut scene, mut ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == ShopKind::Armoury).unwrap(); + scene.open_shop(shop); + ctx.hold.gear = items::Gear::default(); + ctx.hold.lockers.clear(); + let terms = trade::armoury_terms(&ctx.system, scene.place); + let rack = scene.rack(&ctx); + let n = rack.len(); + + ctx.hold.scrip = 0; + assert!(scene.buy(0, &mut ctx).contains("Come back richer")); + let price = terms.asking(&rack[0].item).unwrap(); + ctx.hold.scrip = price + 5; + let said = scene.buy(0, &mut ctx); + assert!(said.ends_with(&format!("is yours, for {price} ¤.")), "{said}"); + assert_eq!((ctx.hold.scrip, scene.rack(&ctx).len()), (5, n - 1)); + assert_eq!(ctx.hold.gear.pack, [rack[0].clone()]); + + // Your own torch goes only with something else to hit things with. + let torch = Item::Weapon(ctx.hold.gear.melee.clone()); + if matches!(&rack[0].item, Item::Weapon(w) if w.is_gun()) { + let said = scene.sell(Some(items::Spot::Melee), None, &mut ctx); + assert!(said.contains("nothing to hit things with"), "{said}"); + assert_eq!(ctx.hold.scrip, 5); + let sold = scene.sell(Some(items::Spot::Pack(0)), None, &mut ctx); + assert!(sold.contains("It'll find a new owner"), "{sold}"); + } else { + let said = scene.sell(Some(items::Spot::Melee), None, &mut ctx); + assert!(said.contains("your cutting torch"), "{said}"); + assert_eq!(Item::Weapon(ctx.hold.gear.melee.clone()), rack[0].item, "readied instead"); + let back = scene.rack(&ctx).iter().position(|s| s.item == torch).unwrap(); + ctx.hold.scrip += 100; + let said = scene.buy(back, &mut ctx); + assert!(said.starts_with("You buy back your cutting torch"), "{said}"); + } + + // And from your ship's lockers. + ctx.hold.lockers.push(rack[1].clone()); + let scrip = ctx.hold.scrip; + scene.sell(None, Some(0), &mut ctx); + assert!(ctx.hold.lockers.is_empty()); + assert_eq!(ctx.hold.scrip, scrip + terms.offer(&rack[1].item).unwrap()); + let now = scene.rack(&ctx); + let copies = now.iter().filter(|s| **s == rack[1]).count(); + assert_eq!(copies, 2, "what you sell goes on the rack"); + + // Come back later in the fortnight and it's as you left it; the + // next, it's restocked. + let again = TownScene::new(&ctx, ctx.system.home_place()); + assert_eq!(again.rack(&ctx), now); + ctx.day += trade::RESTOCK_DAYS; + assert_ne!(again.rack(&ctx), now); + } + + #[test] + fn an_armourer_sells_armour_and_mends_yours() { + let (mut scene, mut ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == ShopKind::Armoury).unwrap(); + scene.open_shop(shop); + ctx.hold.gear = items::Gear::default(); + ctx.hold.lockers.clear(); + let terms = trade::armoury_terms(&ctx.system, scene.place); + let rack = scene.rack(&ctx); + let i = + rack.iter().position(|s| matches!(s.item, Item::Armor(_))).expect("armour on the rack"); + let Item::Armor(piece) = rack[i].item.clone() else { unreachable!() }; + assert!(rack[..i].iter().all(|s| matches!(s.item, Item::Weapon(_))), "weapons first"); + + ctx.hold.scrip = 1000; + let said = scene.buy(i, &mut ctx); + assert!(said.contains("yours, for"), "{said}"); + assert_eq!(ctx.hold.scrip, 1000 - terms.asking(&rack[i].item).unwrap()); + assert_eq!(scene.rack(&ctx).len(), rack.len() - 1); + ctx.hold.gear.wear(0).unwrap(); + + // Worn down, it's mended for what the wear's worth. + let slot = piece.slot; + ctx.hold.gear.worn.get_mut(&slot).unwrap().durability = 0; + let cost = terms.mending(&ctx.hold.gear.worn[&slot]); + assert!(cost > 0); + let scrip = ctx.hold.scrip; + let said = scene.mend(Some(items::Spot::Worn(slot)), None, &mut ctx); + assert!(said.contains("whole again"), "{said}"); + assert_eq!(ctx.hold.scrip, scrip - cost); + assert_eq!(ctx.hold.gear.worn[&slot], piece); + let said = scene.mend(Some(items::Spot::Worn(slot)), None, &mut ctx); + assert!(said.contains("whole already"), "{said}"); + + // All at once: on you and aboard. + ctx.hold.gear.worn.get_mut(&slot).unwrap().durability = 1; + let mut aboard = piece.clone(); + aboard.durability = 2; + ctx.hold.lockers.push(Stack::one(Item::Armor(aboard))); + ctx.hold.scrip = 0; + let said = scene.mend(None, None, &mut ctx); + assert!(said.contains("to mend all 2"), "{said}"); + ctx.hold.scrip = 1000; + let said = scene.mend(None, None, &mut ctx); + assert!(said.starts_with("All 2 pieces are whole again"), "{said}"); + assert_eq!(ctx.hold.lockers[0].item, Item::Armor(piece.clone())); + assert_eq!(scene.mend(None, None, &mut ctx), "Nothing of yours needs mending."); + + // And sold back, off your back. + let said = scene.sell(Some(items::Spot::Worn(slot)), None, &mut ctx); + assert!(said.contains("It'll find a new owner"), "{said}"); + assert!(ctx.hold.gear.worn.is_empty()); + assert!(scene.rack(&ctx).contains(&Stack::one(Item::Armor(piece)))); + } + + #[test] + fn walking_up_to_a_counter_opens_the_shop() { + for kind in ShopKind::ALL { + let (mut scene, mut ctx) = landed(); + let shop = scene.town.shops.iter().position(|s| s.kind == kind).unwrap(); + let b = scene.town.shops[shop].building; + walk_in(&mut scene, &mut ctx, b); + let inside = scene.inside.as_ref().expect("the door opens"); + assert_eq!(inside.building, b); + assert_eq!(scene.player, inside.start); + // Somewhere you can reach that's next to the counter. + let seen = inside.site.reachable(inside.start); + let (stand, counter) = inside + .site + .positions() + .filter(|&p| seen[(p.y * inside.site.width + p.x) as usize]) + .find_map(|p| { + p.neighbours().find(|&n| inside.shop_at(n) == Some(shop)).map(|n| (p, n)) + }) + .unwrap_or_else(|| panic!("can't reach the {kind:?}'s counter")); + scene.inside.as_mut().unwrap().site.people.retain(|p| p.pos != stand || p.stays); + scene.player = stand; + press(&mut scene, &mut ctx, key_towards(counter.x - stand.x, counter.y - stand.y)); + if kind == ShopKind::Armoury { + assert_eq!(scene.shopping, Some(shop), "an armourer shows you the rack"); + assert!(scene.inventory.is_some() && scene.trading.is_none()); + } else { + assert_eq!(scene.trading.as_ref().map(|t| t.shop), Some(shop)); + } + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.trading.is_none(), "esc steps away from the counter"); + assert!(scene.inventory.is_none() && scene.shopping.is_none()); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None)); + assert!(scene.pause.is_some(), "then opens the menu"); + press(&mut scene, &mut ctx, KeyCode::Char('2')); + assert!(scene.inside.is_none(), "which takes you out to the street"); + assert_eq!(scene.player, scene.town.buildings[b].front()); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!( + matches!(press(&mut scene, &mut ctx, KeyCode::Char('2')), Transition::Pop), + "then off" + ); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None)); + assert!(scene.pause.is_none(), "esc again closes the menu"); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!( + matches!(press(&mut scene, &mut ctx, KeyCode::Char('3')), Transition::Title), + "or save and quit" + ); + } + } + + #[test] + fn the_way_in_is_the_way_out() { + let (mut scene, mut ctx) = landed(); + let b = (0..scene.town.buildings.len()) + .find(|&b| { + !scene.town.buildings[b].barred && scene.town.buildings[b].kind == Kind::House + }) + .unwrap(); + walk_in(&mut scene, &mut ctx, b); + let inside = scene.inside.as_ref().unwrap(); + let (exit, start) = (inside.exit, inside.start); + scene.inside.as_mut().unwrap().site.people.retain(|p| p.pos != exit); + assert_eq!(scene.player, start); + press(&mut scene, &mut ctx, key_towards(exit.x - start.x, exit.y - start.y)); + assert!(scene.inside.is_none()); + assert_eq!(scene.player, scene.town.buildings[b].front()); + } + + #[test] + fn barred_doors_stay_shut() { + let (mut scene, mut ctx) = landed(); + let b = scene.town.buildings.iter().position(|b| b.barred).unwrap(); + walk_in(&mut scene, &mut ctx, b); + assert!(scene.inside.is_none()); + assert_eq!(scene.player, scene.town.buildings[b].front()); + } + + #[test] + fn walls_stop_you_and_streets_dont() { + let (mut scene, mut ctx) = landed(); + let start = scene.player; + press(&mut scene, &mut ctx, KeyCode::Right); + assert_eq!(scene.player, start.offset(1, 0)); + // The ship is west of where you start: walking into it leaves. + scene.player = start; + let mut left = Transition::None; + for _ in 0..3 { + left = press(&mut scene, &mut ctx, KeyCode::Left); + } + assert!(matches!(left, Transition::Pop)); + } + + #[test] + fn you_walk_through_people_who_are_wandering() { + let (mut scene, mut ctx) = landed(); + // Out in the streets, three cells of street in a row. + let site = &scene.town.site; + let street = |p: Pos| site.tile(p) == Tile::Street && site.free(p); + let from = site + .positions() + .find(|&p| street(p) && street(p.offset(1, 0)) && street(p.offset(2, 0))) + .expect("a street"); + scene.player = from; + let to = from.offset(1, 0); + scene.town.site.people.push(Person { + pos: to, + role: Role::Passerby, + name: None, + says: "Hello.", + stays: false, + }); + let who = scene.town.site.people.len() - 1; + press(&mut scene, &mut ctx, KeyCode::Right); + assert_eq!(scene.player, to, "you walk on"); + assert_ne!(scene.town.site.people[who].pos, to, "and they step aside"); + // `t` talks to them anyway. + let said = scene.said; + press(&mut scene, &mut ctx, KeyCode::Char('t')); + assert_eq!(scene.said, said + 1); + + // Someone who stays put is in the way, and bumping them talks. + scene.town.site.people[who].pos = to.offset(1, 0); + scene.town.site.people[who].stays = true; + press(&mut scene, &mut ctx, KeyCode::Right); + assert_eq!(scene.player, to); + assert!(scene.log.last().unwrap().contains("Hello.")); + } + + #[test] + fn running_goes_until_something_changes() { + let (mut scene, mut ctx) = landed(); + let start = scene.player; + scene.town.site.people.clear(); + scene.key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), &mut ctx); + let ran = scene.player.x - start.x; + assert!(ran > 3, "ran {ran}"); + assert_eq!(scene.player.y, start.y); + let at = scene.player; + scene.key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), &mut ctx); + assert!(scene.player.x > at.x, "and on again"); + } + + #[test] + fn passers_by_point_the_way() { + let (mut scene, _) = landed(); + let at = scene.town.square; + for _ in 0..20 { + let text = scene.directions(at).unwrap(); + assert!(text.contains(" of here"), "{text}"); + } + assert_eq!(compass(10, 0), "east"); + assert_eq!(compass(0, -5), "north"); + assert_eq!(compass(-6, 3), "south-west"); + } + + #[test] + fn insides_and_the_city_map_render_at_any_size() { + let (mut scene, mut ctx) = landed(); + let mut scenes = Vec::new(); + for kind in [Kind::Palace, Kind::Temple, Kind::Shop(ShopKind::Tavern)] { + let b = scene.town.buildings.iter().position(|b| b.kind == kind).unwrap(); + walk_in(&mut scene, &mut ctx, b); + assert!(scene.inside.is_some()); + scenes.push(scene.town.enter(b)); + scene.go_out(); + } + press(&mut scene, &mut ctx, KeyCode::Char('m')); + assert!(scene.map); + for (width, height) in [(200, 60), (80, 24), (30, 10), (3, 2)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| scene.render(frame, &ctx)).unwrap(); + } + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(!scene.map); + for inside in scenes { + scene.player = inside.start; + scene.inside = Some(inside); + for (width, height) in [(200, 60), (80, 24), (30, 10), (3, 2)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| scene.render(frame, &ctx)).unwrap(); + } + } + } + + #[test] + fn the_broker_buys_your_ore() { + let (mut scene, mut ctx) = at_counter(ShopKind::Broker); + ctx.hold.ore.insert("nickel", 4); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(reply(&scene).starts_with("Sold 4 t")); + assert_eq!(ctx.hold.ore_tons(), 0); + assert!(ctx.hold.scrip > 0); + } + + #[test] + fn the_doctor_sells_medpatches_you_can_afford() { + let (mut scene, mut ctx) = at_counter(ShopKind::Doctor); + press(&mut scene, &mut ctx, KeyCode::Enter); + assert_eq!(reply(&scene), "You can't afford that."); + ctx.hold.scrip = 100; + press(&mut scene, &mut ctx, KeyCode::Enter); + assert_eq!(ctx.hold.gear.medpatches(), 1); + assert_eq!(ctx.hold.scrip, 100 - trade::medpatch_price(&ctx.system, scene.place)); + // What won't fit on you goes to your ship. + ctx.hold.gear.capacity = ctx.hold.gear.carried(); + press(&mut scene, &mut ctx, KeyCode::Enter); + assert!(reply(&scene).contains("sent to your ship"), "{}", reply(&scene)); + assert_eq!((ctx.hold.gear.medpatches(), ctx.hold.medpatches()), (1, 2)); + ctx.hold.lockers.push(Stack { item: Item::Medpatch, count: trade::MEDPATCH_LIMIT }); + let scrip = ctx.hold.scrip; + press(&mut scene, &mut ctx, KeyCode::Enter); + assert_eq!(ctx.hold.scrip, scrip, "no more than the limit"); + } + + #[test] + fn long_names_wrap_between_words() { + assert_eq!( + hanging("the Voidsteel Crown of Drakion", 24), + ["the Voidsteel Crown of", "Drakion"] + ); + assert_eq!(hanging("a disc", 24), ["a disc"]); + } + + #[test] + fn the_scholar_names_artifacts_then_buys_them() { + let (mut scene, mut ctx) = at_counter(ShopKind::Scholar); + let artifact = names::artifact(&mut seed::rng(9)); + let true_name = artifact.true_name.clone(); + ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); + ctx.hold.scrip = 30; + assert!(screen(&scene, &ctx).contains("(in your ship)")); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(matches!(&ctx.hold.lockers[0].item, Item::Artifact(a) if a.identified)); + assert!(reply(&scene).contains(&true_name)); + let before = ctx.hold.scrip; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(ctx.hold.lockers.is_empty()); + assert!(ctx.hold.scrip > before); + } + + fn screen(scene: &TownScene, ctx: &Ctx) -> String { + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() + } + + fn salvage(title: &str, value: u32) -> Item { + Item::Salvage(items::Salvage { title: title.into(), value, mass: 1000 }) + } + + #[test] + fn the_broker_buys_salvage_on_you_or_aboard() { + let (mut scene, mut ctx) = at_counter(ShopKind::Broker); + ctx.hold.gear.add(Stack { item: salvage("coil of wire", 10), count: 3 }); + ctx.hold.lockers.push(Stack::one(salvage("hull plate", 20))); + assert!(screen(&scene, &ctx).contains("Sell all your salvage")); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(reply(&scene).contains("3 coils of wire"), "{}", reply(&scene)); + assert!(ctx.hold.gear.pack.iter().all(|s| !matches!(s.item, Item::Salvage(_)))); + let paid = ctx.hold.scrip; + assert!(paid >= 3 * 7, "about what it's worth"); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert!(ctx.hold.lockers.is_empty()); + assert!(ctx.hold.scrip > paid); + } + + #[test] + fn the_shipyard_sells_bigger_packs() { + let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard); + ctx.hold.scrip = 5000; + ctx.hold.ore_capacity = 1000; + ctx.lenses = 10; + ctx.hold.fuel_capacity = trade::MAX_FUEL; + ctx.hold.fuel = trade::MAX_FUEL; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.gear.capacity, items::BASE_CAPACITY + items::PACKS[0].1); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.gear.capacity, items::max_capacity()); + assert!(screen(&scene, &ctx).contains("nothing more")); + } + + #[test] + fn what_you_drop_in_the_city_stays_put_until_you_leave() { + let (mut scene, mut ctx) = landed(); + ctx.hold.gear.add(Stack::one(salvage("coil of wire", 10))); + let start = scene.player; + press(&mut scene, &mut ctx, KeyCode::Char('i')); + for c in "/wire".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Enter); + press(&mut scene, &mut ctx, KeyCode::Char('d')); + assert_eq!(inventory::lying(&scene.dropped, (None, start)).len(), 1); + press(&mut scene, &mut ctx, KeyCode::Char('q')); + assert!(scene.inventory.is_none()); + press(&mut scene, &mut ctx, KeyCode::Char('g')); + assert!(scene.dropped.is_empty()); + assert!(ctx.hold.gear.pack.iter().any(|s| matches!(s.item, Item::Salvage(_)))); + } + + #[test] + fn the_tavern_trades_rumours_for_rounds() { + let (mut scene, mut ctx) = at_counter(ShopKind::Tavern); + ctx.hold.scrip = 10; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + let first = reply(&scene).to_string(); + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_ne!(reply(&scene), first, "a new rumour each round"); + assert_eq!(ctx.hold.scrip, 10 - 2 * trade::ROUND_PRICE); + } + + #[test] + fn the_shipyard_fits_racks_and_lenses() { + let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard); + ctx.hold.scrip = 2000; + // A full tank: no fuel on offer, so racks are first. + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.ore_capacity, Hold::BASE_ORE_CAPACITY + trade::RACK_TONS); + press(&mut scene, &mut ctx, KeyCode::Char('2')); + assert_eq!(ctx.lenses, 1); + press(&mut scene, &mut ctx, KeyCode::Char('3')); + assert_eq!(ctx.hold.fuel_capacity, Hold::BASE_FUEL + trade::TANK_JUMPS); + } + + #[test] + fn the_shipyard_sells_fuel() { + let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard); + ctx.hold.fuel = 0; + ctx.hold.scrip = 2000; + press(&mut scene, &mut ctx, KeyCode::Char('1')); + assert_eq!(ctx.hold.fuel, ctx.hold.fuel_capacity); + assert!(reply(&scene).contains("4 jumps of fuel"), "{}", reply(&scene)); + } +} diff --git a/src/scenes/zz_dump.rs b/src/scenes/zz_dump.rs new file mode 100644 index 0000000..241f342 --- /dev/null +++ b/src/scenes/zz_dump.rs @@ -0,0 +1,62 @@ +//! Temporary: prints screens for a UI review. +use super::tests::test_ctx; +use super::*; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +fn show(name: &str, scene: &dyn Scene, ctx: &Ctx, w: u16, h: u16) { + let mut t = Terminal::new(TestBackend::new(w, h)).unwrap(); + t.draw(|f| scene.render(f, ctx)).unwrap(); + let b = t.backend().buffer(); + let cells: Vec<&str> = b.content.iter().map(|c| c.symbol()).collect(); + println!("===== {name} ({w}x{h})"); + for row in cells.chunks(w as usize) { + println!("{}", row.concat().trim_end()); + } +} +fn k(s: &mut dyn Scene, ctx: &mut Ctx, c: KeyCode) { + s.key(KeyEvent::new(c, KeyModifiers::NONE), ctx); +} + +#[test] +fn zz_dump() { + let mut ctx = test_ctx(); + let dir = crate::save::scratch("dump"); + ctx.saves = Some(dir.clone()); + ctx.slot = Some(crate::save::new_slot(&dir, &ctx.character, ctx.seed)); + ctx.autosave(); + let (w, h) = (120, 40); + show("title", &title::Title::new(&ctx), &ctx, w, h); + show("create", &character::Create::new(&ctx), &ctx, w, h); + show("sheet", &character::Sheet::new(), &ctx, w, h); + show("saves", &saves::Saves::new(&ctx), &ctx, w, h); + show("forge", &forge::Forge::new(&ctx), &ctx, w, h); + let home = ctx.system.home_place(); + let mut a = aboard::Aboard::landed(&ctx, home); + show("aboard", &a, &ctx, w, h); + k(&mut a, &mut ctx, KeyCode::Esc); + show("aboard pause", &a, &ctx, w, h); + let mut a = aboard::Aboard::landed(&ctx, home); + k(&mut a, &mut ctx, KeyCode::Char('i')); + show("aboard inv", &a, &ctx, w, h); + let mut t = town::TownScene::new(&ctx, home); + show("town", &t, &ctx, w, h); + k(&mut t, &mut ctx, KeyCode::Char('m')); + show("town map", &t, &ctx, w, h); + let mut f = flight::FlightScene::new(&ctx); + show("flight", &f, &ctx, w, h); + show("flight narrow", &f, &ctx, 80, 24); + show("aboard 80", &aboard::Aboard::landed(&ctx, home), &ctx, 80, 24); + show("derelict 80", &derelict::Derelict::new(2), &ctx, 80, 24); + k(&mut f, &mut ctx, KeyCode::Esc); + show("flight pause", &f, &ctx, w, h); + show("system map", &system_map::SystemMap::new(&ctx), &ctx, w, h); + show("galaxy map", &galaxy_map::GalaxyMap::new(&ctx), &ctx, w, h); + show("port station", &port::Port::new(&ctx, 0), &ctx, w, h); + let mut d = derelict::Derelict::board(2, &mut ctx); + show("derelict", &d, &ctx, w, h); + k(&mut d, &mut ctx, KeyCode::Char('f')); + show("derelict aim", &d, &ctx, w, h); + let _ = std::fs::remove_dir_all(dir); +} |