//! 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")); } }