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