summaryrefslogtreecommitdiff
path: root/src/scenes/inventory.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/scenes/inventory.rs')
-rw-r--r--src/scenes/inventory.rs1808
1 files changed, 1808 insertions, 0 deletions
diff --git a/src/scenes/inventory.rs b/src/scenes/inventory.rs
new file mode 100644
index 0000000..d9dd249
--- /dev/null
+++ b/src/scenes/inventory.rs
@@ -0,0 +1,1808 @@
+//! The inventory, after Cataclysm's: everything on you, grouped by what it
+//! is, with all about whatever's picked out; and beside it, when there is
+//! one, another pile to move things to and from: your ship's lockers, what's
+//! on the floor where you stand, or an armourer's rack to buy from and sell
+//! to, with your armour to mend.
+//!
+//! Vim keys throughout: `j` `k` move, `gg` `G` go to the top and bottom,
+//! ctrl-d and ctrl-u by half a page, `h` `l` between the lists, `/` finds.
+//! `o` sorts both lists by something else (`Sort`), within each kind of
+//! thing, and `O` turns the sort round. Every row shows what it weighs and
+//! what it would fetch.
+//! A count before a key does it that many times: `3d` drops three, `D` the
+//! whole stack. `enter` lists everything you can do with what's picked out.
+//!
+//! It isn't a scene of its own. The scene you're walking about in opens it
+//! over itself and carries out what it asks for (`Command`), since only
+//! that scene knows what putting something down means there, and whether
+//! it takes a turn.
+
+use std::cell::Cell;
+use std::collections::BTreeSet;
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap};
+use serde::{Deserialize, Serialize};
+
+use super::keys;
+use super::sky::rgb;
+use crate::dungeon::armour::Armor;
+use crate::dungeon::arms::{Condition, Dmg, Weapon};
+use crate::dungeon::crawl::{Affinity, MEDPATCH_HEAL, MONSTERS};
+use crate::items::{self, Category, Gear, Item, LOCKER_CAPACITY, Slot, Spot, Stack};
+use crate::space::system::Rgb;
+use crate::trade::{self, Terms};
+
+const ACCENT: Color = Color::Rgb(240, 200, 110);
+/// What something would fetch somewhere else: about this.
+const GUESS: Color = Color::Rgb(184, 166, 120);
+const DIM: Color = Color::Rgb(120, 124, 142);
+const HEADING: Color = Color::Rgb(160, 166, 190);
+/// Wider than this, the details sit beside the lists rather than below.
+const WIDE: u16 = 110;
+/// A list narrower than this shows what things fetch, but not what they
+/// weigh.
+const NARROW: usize = 30;
+/// How far in things sit from the heading they're under.
+const INDENT: &str = " ";
+const DETAILS_HEIGHT: u16 = 13;
+
+/// How something looks lying about: a glyph and a colour.
+pub fn look(item: &Item) -> (char, Rgb) {
+ match item {
+ Item::Weapon(w) => (if w.is_gun() { '}' } else { ')' }, w.dmg.rgb()),
+ Item::Armor(_) => ('[', (170, 182, 204)),
+ Item::Food(_) => ('%', (214, 176, 112)),
+ Item::Medpatch => ('!', (240, 96, 96)),
+ Item::Artifact(_) => ('*', (255, 214, 92)),
+ Item::Salvage(_) => ('(', (150, 200, 222)),
+ }
+}
+
+/// The pile beside you.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Beside {
+ /// Your ship's lockers.
+ Lockers,
+ /// The floor where you stand: "on the floor", "on the deck".
+ Floor(&'static str),
+ /// An armourer's rack: what's for sale (see `View::shop`).
+ Rack,
+}
+
+/// The armourer you're trading with, whose rack is the pile beside you.
+#[derive(Clone, Copy)]
+pub struct Counter<'a> {
+ pub terms: Terms,
+ /// What's in your ship's lockers: they'll buy from there too.
+ pub aboard: &'a [Stack],
+ pub scrip: u32,
+}
+
+/// What the lists are sorted by, within each kind of thing.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SortBy {
+ #[default]
+ Name,
+ Weight,
+ /// What it fetches, or here costs.
+ Price,
+ /// Price for its weight.
+ Density,
+ /// Armour, most worn first; the rest after.
+ Durability,
+ /// Weapons by their average damage, armour by what it guards.
+ Strength,
+ /// The order you came by things in.
+ Newest,
+}
+
+impl SortBy {
+ const ALL: [SortBy; 7] = [
+ SortBy::Name,
+ SortBy::Weight,
+ SortBy::Price,
+ SortBy::Density,
+ SortBy::Durability,
+ SortBy::Strength,
+ SortBy::Newest,
+ ];
+
+ /// The sort after this one, round to the first.
+ fn next(self) -> SortBy {
+ let i = SortBy::ALL.iter().position(|&s| s == self).unwrap_or(0);
+ SortBy::ALL[(i + 1) % SortBy::ALL.len()]
+ }
+}
+
+/// How the lists are sorted: by what, and which way round.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Sort {
+ pub by: SortBy,
+ /// The other way round from usual: names z–a, the lightest first, the
+ /// cheapest first...
+ #[serde(default)]
+ pub reversed: bool,
+}
+
+impl Sort {
+ /// How it reads: "heaviest first", "by name".
+ pub fn says(self) -> &'static str {
+ match (self.by, self.reversed) {
+ (SortBy::Name, false) => "by name",
+ (SortBy::Name, true) => "by name, z–a",
+ (SortBy::Weight, false) => "heaviest first",
+ (SortBy::Weight, true) => "lightest first",
+ (SortBy::Price, false) => "dearest first",
+ (SortBy::Price, true) => "cheapest first",
+ (SortBy::Density, false) => "most ¤/kg first",
+ (SortBy::Density, true) => "least ¤/kg first",
+ (SortBy::Durability, false) => "most worn first",
+ (SortBy::Durability, true) => "least worn first",
+ (SortBy::Strength, false) => "strongest first",
+ (SortBy::Strength, true) => "weakest first",
+ (SortBy::Newest, false) => "newest first",
+ (SortBy::Newest, true) => "oldest first",
+ }
+ }
+}
+
+/// What something would fetch, or cost, as a row shows it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Price {
+ /// What the armourer you're at asks or pays, and whether you can pay.
+ Here(u32, bool),
+ /// About what a buyer elsewhere would pay.
+ About(u32),
+ /// A scholar has yet to tell you what it is.
+ Unknown,
+ /// No one buys it.
+ Nothing,
+}
+
+impl Price {
+ fn amount(self) -> Option<u32> {
+ match self {
+ Price::Here(n, _) | Price::About(n) => Some(n),
+ Price::Unknown | Price::Nothing => None,
+ }
+ }
+}
+
+/// What a scene shows the inventory, each time it asks it anything.
+#[derive(Clone, Copy)]
+pub struct View<'a> {
+ pub gear: &'a Gear,
+ /// The pile beside you, if there's one, and what's in it.
+ pub beside: Option<(Beside, &'a [Stack])>,
+ /// How you know monsters take each kind of damage.
+ pub known: &'a BTreeSet<(&'static str, Dmg)>,
+ /// Whether you can be hurt here, and so use a medpatch: only in a wreck.
+ pub hurt: bool,
+ /// The shop you're trading with, if you're at an armourer's.
+ pub shop: Option<Counter<'a>>,
+ /// How the lists are sorted.
+ pub sort: Sort,
+}
+
+/// What the inventory asks the scene to do.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Command {
+ /// Ready the weapon at this index in your pack.
+ Ready(usize),
+ /// Put on the armour at this index in your pack.
+ Wear(usize),
+ TakeOff(Slot),
+ /// Eat one of the food at this index in your pack.
+ Eat(usize),
+ UseMedpatch,
+ /// Put down this many of what's at a spot on you, where you stand.
+ Drop(Spot, u32),
+ /// Put this many of what's at a spot on you in the lockers.
+ Stow(Spot, u32),
+ /// Take this many of the `n`th stack in the pile beside you.
+ Fetch(usize, u32),
+ /// Buy the `n`th thing on the rack.
+ Buy(usize),
+ /// Sell what's at a spot on you.
+ Sell(Spot),
+ /// Sell the `n`th stack in your ship's lockers.
+ SellAboard(usize),
+ /// Have the armour at a spot on you mended.
+ Mend(Spot),
+ /// Have the armour that's the `n`th stack in your ship's lockers mended.
+ MendAboard(usize),
+ /// Have all your armour mended, on you and aboard.
+ MendAll,
+}
+
+/// How the inventory answers a key.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Reply {
+ Stay,
+ Close,
+ Do(Command),
+ /// Sort the lists this way from now on. Takes no time.
+ Sort(Sort),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Row {
+ Heading(&'static str),
+ /// Something on you, or a slot to wear something in with nothing in it.
+ You(Spot),
+ /// The `n`th stack in the pile beside you.
+ Beside(usize),
+ /// The `n`th stack in your ship's lockers, when you're selling.
+ Aboard(usize),
+}
+
+/// One thing you can do with what's picked out.
+struct Act {
+ key: char,
+ what: String,
+ command: Command,
+}
+
+pub struct Inventory {
+ /// Whether the cursor is in the pile beside you, rather than on you.
+ on_beside: bool,
+ /// Where the cursor is in each list, counting only rows you can pick.
+ cursor: [usize; 2],
+ /// A count typed before a key.
+ count: Option<u32>,
+ /// `g` pressed once, waiting for a second.
+ g: bool,
+ /// Typing what to find, after `/`.
+ finding: bool,
+ filter: String,
+ /// Picking from what you can do with what's picked out, on `enter`.
+ menu: Option<usize>,
+ /// What happened last.
+ pub note: Option<String>,
+ /// How many rows the lists showed when last drawn, for paging.
+ page: Cell<usize>,
+}
+
+impl Default for Inventory {
+ fn default() -> Inventory {
+ Inventory::new()
+ }
+}
+
+impl Inventory {
+ pub fn new() -> Inventory {
+ Inventory {
+ on_beside: false,
+ cursor: [0, 0],
+ count: None,
+ g: false,
+ finding: false,
+ filter: String::new(),
+ menu: None,
+ note: None,
+ page: Cell::new(10),
+ }
+ }
+
+ /// Opens with the cursor on the pile beside you: to pick things up.
+ pub fn beside() -> Inventory {
+ Inventory { on_beside: true, ..Inventory::new() }
+ }
+
+ fn matches(&self, item: &Item) -> bool {
+ self.filter.is_empty()
+ || item.label().to_lowercase().contains(&self.filter.to_lowercase())
+ || item.category().heading().to_lowercase().contains(&self.filter.to_lowercase())
+ }
+
+ /// The rows of one list, headings and all.
+ fn rows(&self, view: &View, beside: bool) -> Vec<Row> {
+ let mut rows = Vec::new();
+ let pile: Vec<(Row, &Stack)> = if beside {
+ let Some((_, stacks)) = view.beside else { return rows };
+ stacks.iter().enumerate().map(|(i, s)| (Row::Beside(i), s)).collect()
+ } else {
+ let g = view.gear;
+ let mut worn = Vec::new();
+ for slot in Slot::ALL {
+ match g.worn.get(&slot) {
+ Some(a) if self.matches(&Item::Armor(a.clone())) => {
+ worn.push(Row::You(Spot::Worn(slot)));
+ }
+ None if self.filter.is_empty() => worn.push(Row::You(Spot::Worn(slot))),
+ _ => {}
+ }
+ }
+ if !worn.is_empty() {
+ rows.push(Row::Heading("WORN"));
+ rows.extend(worn);
+ }
+ let ready: Vec<Row> = [(Spot::Melee, &g.melee), (Spot::Gun, &g.gun)]
+ .into_iter()
+ .filter(|(_, w)| self.matches(&Item::Weapon((*w).clone())))
+ .map(|(spot, _)| Row::You(spot))
+ .collect();
+ if !ready.is_empty() {
+ rows.push(Row::Heading("READY"));
+ rows.extend(ready);
+ }
+ g.pack.iter().enumerate().map(|(i, s)| (Row::You(Spot::Pack(i)), s)).collect()
+ };
+ for category in Category::ALL {
+ let mut these: Vec<&(Row, &Stack)> = pile
+ .iter()
+ .filter(|(_, s)| s.item.category() == category && self.matches(&s.item))
+ .collect();
+ if these.is_empty() {
+ continue;
+ }
+ these.sort_by(|a, b| order(view, a, b));
+ rows.push(Row::Heading(category.heading()));
+ rows.extend(these.into_iter().map(|(row, _)| *row));
+ }
+ if !beside && let Some(shop) = &view.shop {
+ let mut aboard: Vec<(Row, &Stack)> = shop
+ .aboard
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| shop.terms.offer(&s.item).is_some() && self.matches(&s.item))
+ .map(|(i, s)| (Row::Aboard(i), s))
+ .collect();
+ aboard.sort_by(|a, b| order(view, a, b));
+ let aboard: Vec<Row> = aboard.into_iter().map(|(row, _)| row).collect();
+ if !aboard.is_empty() {
+ rows.push(Row::Heading("IN YOUR SHIP"));
+ rows.extend(aboard);
+ }
+ }
+ rows
+ }
+
+ /// The rows you can pick in one list.
+ fn picks(&self, view: &View, beside: bool) -> Vec<Row> {
+ self.rows(view, beside).into_iter().filter(|r| !matches!(r, Row::Heading(_))).collect()
+ }
+
+ fn side(&self, view: &View) -> bool {
+ self.on_beside && view.beside.is_some()
+ }
+
+ /// The row picked out, if there's anything to pick.
+ fn picked(&self, view: &View) -> Option<Row> {
+ let side = self.side(view);
+ let picks = self.picks(view, side);
+ let i = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1));
+ picks.get(i).copied()
+ }
+
+ fn stack(view: &View, row: Row) -> Option<Stack> {
+ match row {
+ Row::Heading(_) => None,
+ Row::You(spot) => view.gear.get(spot),
+ Row::Beside(i) => view.beside.and_then(|(_, s)| s.get(i)).cloned(),
+ Row::Aboard(i) => view.shop.as_ref().and_then(|s| s.aboard.get(i)).cloned(),
+ }
+ }
+
+ /// Everything you can do with what's picked out, with `n` as the count.
+ fn acts(view: &View, row: Row, n: u32) -> Vec<Act> {
+ let mut acts = Vec::new();
+ let Some(stack) = Self::stack(view, row) else { return acts };
+ let mut act = |key, what: String, command| acts.push(Act { key, what, command });
+ let some = n.clamp(1, stack.count);
+ let how_many = |verb: &str| match (stack.count, some) {
+ (1, _) => format!("{verb} it"),
+ (_, 1) => format!("{verb} one"),
+ (_, n) => format!("{verb} {n}"),
+ };
+ let offer = view.shop.as_ref().and_then(|s| s.terms.offer(&stack.item));
+ match row {
+ Row::Heading(_) => {}
+ Row::You(spot) => {
+ match (spot, &stack.item) {
+ (Spot::Pack(i), Item::Weapon(_)) => {
+ act('w', "ready it".into(), Command::Ready(i))
+ }
+ (Spot::Pack(i), Item::Armor(_)) => act('W', "wear it".into(), Command::Wear(i)),
+ (Spot::Worn(slot), _) => {
+ act('T', "take it off".into(), Command::TakeOff(slot));
+ }
+ (Spot::Pack(i), Item::Food(_)) => act('E', "eat one".into(), Command::Eat(i)),
+ (Spot::Pack(_), Item::Medpatch) if view.hurt => {
+ act('a', "use one".into(), Command::UseMedpatch);
+ }
+ _ => {}
+ }
+ // A ready weapon goes only if there's another to take its place.
+ let spared = match spot {
+ Spot::Melee => view.gear.spare(false).is_some(),
+ Spot::Gun => view.gear.spare(true).is_some(),
+ _ => true,
+ };
+ if let Some(price) = offer.filter(|_| spared) {
+ act('s', format!("sell it for {price} ¤"), Command::Sell(spot));
+ }
+ if let Some(cost) = mend_cost(view, &stack.item) {
+ act('r', format!("mend it for {cost} ¤"), Command::Mend(spot));
+ }
+ if spared {
+ act('d', how_many("drop"), Command::Drop(spot, some));
+ if stack.count > 1 {
+ act('D', "drop them all".into(), Command::Drop(spot, stack.count));
+ }
+ if let Some((Beside::Lockers, _)) = view.beside {
+ act('m', how_many("stow"), Command::Stow(spot, some));
+ if stack.count > 1 {
+ act('M', "stow them all".into(), Command::Stow(spot, stack.count));
+ }
+ }
+ }
+ }
+ Row::Beside(i) => match (view.beside, &view.shop) {
+ (Some((Beside::Rack, _)), Some(shop)) => {
+ if let Some(price) = shop.terms.asking(&stack.item) {
+ act('b', format!("buy it for {price} ¤"), Command::Buy(i));
+ }
+ }
+ _ => {
+ act('m', how_many("take"), Command::Fetch(i, some));
+ if stack.count > 1 {
+ act('M', "take them all".into(), Command::Fetch(i, stack.count));
+ }
+ }
+ },
+ Row::Aboard(i) => {
+ if let Some(price) = offer {
+ act('s', format!("sell it for {price} ¤"), Command::SellAboard(i));
+ }
+ if let Some(cost) = mend_cost(view, &stack.item) {
+ act('r', format!("mend it for {cost} ¤"), Command::MendAboard(i));
+ }
+ }
+ }
+ if !matches!(row, Row::Beside(_))
+ && let Some((n, cost)) = mend_all_cost(view)
+ && n > 1
+ && matches!(stack.item, Item::Armor(_))
+ {
+ act('R', format!("mend all {n} for {cost} ¤"), Command::MendAll);
+ }
+ acts
+ }
+
+ fn move_by(&mut self, view: &View, by: isize) {
+ let side = self.side(view);
+ let last = self.picks(view, side).len().saturating_sub(1);
+ let cursor = &mut self.cursor[usize::from(side)];
+ *cursor = (*cursor).min(last).saturating_add_signed(by).min(last);
+ }
+
+ pub fn key(&mut self, key: KeyEvent, view: &View) -> Reply {
+ if self.finding {
+ match key.code {
+ KeyCode::Enter => self.finding = false,
+ KeyCode::Esc => {
+ self.finding = false;
+ self.filter.clear();
+ }
+ KeyCode::Backspace if self.filter.is_empty() => self.finding = false,
+ KeyCode::Backspace => {
+ self.filter.pop();
+ }
+ KeyCode::Char(c) => self.filter.push(c),
+ _ => {}
+ }
+ self.cursor = [0, 0];
+ return Reply::Stay;
+ }
+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
+ if let Some(choice) = self.menu {
+ let Some(row) = self.picked(view) else {
+ self.menu = None;
+ return Reply::Stay;
+ };
+ let acts = Self::acts(view, row, self.count.unwrap_or(1));
+ let last = acts.len().saturating_sub(1);
+ let chosen = match key.code {
+ KeyCode::Down | KeyCode::Char('j') => {
+ self.menu = Some((choice + 1).min(last));
+ None
+ }
+ KeyCode::Up | KeyCode::Char('k') => {
+ self.menu = Some(choice.saturating_sub(1));
+ None
+ }
+ KeyCode::Enter | KeyCode::Char('l') => acts.get(choice),
+ KeyCode::Esc | KeyCode::Char('q' | 'h') => {
+ self.menu = None;
+ None
+ }
+ KeyCode::Char(c) => acts.iter().find(|a| a.key == c),
+ _ => None,
+ };
+ return match chosen {
+ Some(act) => {
+ self.menu = None;
+ self.count = None;
+ Reply::Do(act.command)
+ }
+ None => Reply::Stay,
+ };
+ }
+ if let KeyCode::Char(c @ '0'..='9') = key.code
+ && !ctrl
+ && (c != '0' || self.count.is_some())
+ {
+ let digit = c.to_digit(10).unwrap_or(0);
+ self.count = Some((self.count.unwrap_or(0) * 10 + digit).min(9999));
+ return Reply::Stay;
+ }
+ let count = self.count.take();
+ let n = count.unwrap_or(1);
+ let twice = std::mem::take(&mut self.g);
+ let half = (self.page.get() / 2).max(1) as isize;
+ let side = self.side(view);
+ match key.code {
+ KeyCode::Char('d') if ctrl => self.move_by(view, half),
+ KeyCode::Char('u') if ctrl => self.move_by(view, -half),
+ KeyCode::Char('f') if ctrl => self.move_by(view, half * 2),
+ KeyCode::Char('b') if ctrl => self.move_by(view, -half * 2),
+ KeyCode::PageDown => self.move_by(view, half * 2),
+ KeyCode::PageUp => self.move_by(view, -half * 2),
+ KeyCode::Down | KeyCode::Char('j') => self.move_by(view, n as isize),
+ KeyCode::Up | KeyCode::Char('k') => self.move_by(view, -(n as isize)),
+ KeyCode::Home => self.cursor[usize::from(side)] = 0,
+ KeyCode::Char('g') if twice => self.cursor[usize::from(side)] = 0,
+ KeyCode::Char('g') => self.g = true,
+ KeyCode::End | KeyCode::Char('G') => {
+ let last = self.picks(view, side).len().saturating_sub(1);
+ let to = count.map_or(last, |n| (n as usize).saturating_sub(1).min(last));
+ self.cursor[usize::from(side)] = to;
+ }
+ KeyCode::Left | KeyCode::Char('h') => self.on_beside = false,
+ KeyCode::Right | KeyCode::Char('l') => self.on_beside = view.beside.is_some(),
+ KeyCode::Tab | KeyCode::BackTab => self.on_beside = !side && view.beside.is_some(),
+ KeyCode::Char('/') => {
+ self.finding = true;
+ self.filter.clear();
+ self.cursor = [0, 0];
+ }
+ KeyCode::Esc if !self.filter.is_empty() => {
+ self.filter.clear();
+ self.cursor = [0, 0];
+ }
+ KeyCode::Esc | KeyCode::Char('q' | 'i') => return Reply::Close,
+ KeyCode::Char('o') => {
+ return self.sort(view, Sort { by: view.sort.by.next(), reversed: false });
+ }
+ KeyCode::Char('O') => {
+ return self.sort(view, Sort { reversed: !view.sort.reversed, ..view.sort });
+ }
+ KeyCode::Enter => {
+ if let Some(row) = self.picked(view)
+ && !Self::acts(view, row, n).is_empty()
+ {
+ self.menu = Some(0);
+ self.count = count;
+ }
+ }
+ KeyCode::Char(c) => {
+ if let Some(row) = self.picked(view)
+ && let Some(act) = Self::acts(view, row, n).into_iter().find(|a| a.key == c)
+ {
+ return Reply::Do(act.command);
+ }
+ // Wherever the cursor is, at an armourer's.
+ if c == 'R' && view.shop.is_some() {
+ return Reply::Do(Command::MendAll);
+ }
+ }
+ _ => {}
+ }
+ Reply::Stay
+ }
+
+ /// Sorts the lists another way, keeping the cursor on what it was on.
+ fn sort(&mut self, view: &View, sort: Sort) -> Reply {
+ let sorted = View { sort, ..*view };
+ for side in [false, true] {
+ let picks = self.picks(view, side);
+ let at = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1));
+ if let Some(row) = picks.get(at) {
+ let now = self.picks(&sorted, side).iter().position(|r| r == row);
+ self.cursor[usize::from(side)] = now.unwrap_or(0);
+ }
+ }
+ Reply::Sort(sort)
+ }
+
+ /// Draws the inventory over the whole of `area`.
+ pub fn render(&self, frame: &mut Frame, area: Rect, view: &View) {
+ frame.render_widget(Clear, area);
+ let [body, foot] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(area);
+ let wide = body.width >= WIDE;
+ // Below the lists, on a short screen the details give up room first.
+ let details_height = (body.height * 2 / 5).clamp(6, DETAILS_HEIGHT);
+ let (you, beside, details) = match view.beside {
+ Some(_) if wide => {
+ let [a, b, c] = Layout::horizontal([Constraint::Fill(1); 3]).areas(body);
+ (a, Some(b), c)
+ }
+ Some(_) => {
+ let [lists, details] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)])
+ .areas(body);
+ let [a, b] = Layout::horizontal([Constraint::Fill(1); 2]).areas(lists);
+ (a, Some(b), details)
+ }
+ None if body.width >= 72 => {
+ let [a, c] =
+ Layout::horizontal([Constraint::Fill(3), Constraint::Fill(2)]).areas(body);
+ (a, None, c)
+ }
+ None => {
+ let [a, c] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)])
+ .areas(body);
+ (a, None, c)
+ }
+ };
+ self.draw_list(frame, you, view, false);
+ if let Some(beside) = beside {
+ self.draw_list(frame, beside, view, true);
+ }
+ self.draw_details(frame, details, view);
+ self.draw_foot(frame, foot, view.shop.is_some());
+ }
+
+ fn draw_list(&self, frame: &mut Frame, area: Rect, view: &View, beside: bool) {
+ let focused = self.side(view) == beside;
+ let title = if beside {
+ match view.beside {
+ Some((Beside::Lockers, stacks)) => format!(
+ " LOCKERS · {} / {} kg ",
+ items::kg(items::pile_mass(stacks)),
+ items::kg(LOCKER_CAPACITY)
+ ),
+ Some((Beside::Floor(here), _)) => format!(" {} ", here.to_uppercase()),
+ Some((Beside::Rack, _)) => {
+ format!(" FOR SALE · you have {} ¤ ", view.shop.as_ref().map_or(0, |s| s.scrip))
+ }
+ None => String::new(),
+ }
+ } else {
+ let g = view.gear;
+ format!(
+ " YOU · {} / {} kg · {} ",
+ items::kg(g.carried()),
+ items::kg(g.capacity),
+ view.sort.says()
+ )
+ };
+ let colour = if focused { ACCENT } else { DIM };
+ let block = Block::bordered()
+ .border_style(Style::new().fg(colour))
+ .title(Line::from(title).fg(colour).bold());
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let rows = self.rows(view, beside);
+ let picked = self.picked(view);
+ let height = usize::from(inner.height);
+ if focused {
+ self.page.set(height);
+ }
+ let at = rows.iter().position(|r| Some(*r) == picked && focused).unwrap_or(0);
+ // Keep the picked row in view, with its heading if there's room.
+ let first =
+ at.saturating_sub(height.saturating_sub(1) / 2).min(rows.len().saturating_sub(height));
+ let width = usize::from(inner.width);
+ // Which column is which, over the first heading in sight.
+ let mut labelled = false;
+ let mut lines: Vec<Line> = rows
+ .iter()
+ .skip(first)
+ .take(height)
+ .map(|&row| {
+ let line = match row {
+ Row::Heading(text) => {
+ heading(text, width, !std::mem::replace(&mut labelled, true))
+ }
+ _ => self.row_line(view, row, width),
+ };
+ if Some(row) == picked && focused {
+ line.patch_style(Style::new().add_modifier(Modifier::REVERSED))
+ } else {
+ line
+ }
+ })
+ .collect();
+ if rows.is_empty() {
+ let rack = matches!(view.beside, Some((Beside::Rack, _)));
+ let empty = match (beside, self.filter.is_empty()) {
+ (_, false) => "Nothing like that.",
+ (true, true) if rack => "The rack's bare.",
+ (true, true) => "Nothing here.",
+ (false, true) => "Nothing on you.",
+ };
+ lines.push(Line::from(format!(" {empty}")).fg(DIM));
+ }
+ frame.render_widget(Paragraph::new(lines), inner);
+ }
+
+ fn row_line(&self, view: &View, row: Row, width: usize) -> Line<'static> {
+ let weighed = width >= NARROW;
+ let label = match row {
+ Row::Heading(text) => return heading(text, width, true),
+ Row::You(Spot::Worn(slot)) => format!("{:<6}", slot.name()),
+ Row::You(Spot::Melee) => "melee ".into(),
+ Row::You(Spot::Gun) => "gun ".into(),
+ _ => String::new(),
+ };
+ let Some(stack) = Self::stack(view, row) else {
+ return Line::from(vec![
+ Span::raw(format!("{INDENT} {label}")).fg(DIM),
+ Span::raw("—").fg(DIM),
+ ]);
+ };
+ let (glyph, colour) = look(&stack.item);
+ let mut name = stack.item.label();
+ if stack.count > 1 {
+ name = format!("{name} ×{}", stack.count);
+ }
+ if let Item::Weapon(w) = &stack.item
+ && let Some(g) = w.gun
+ {
+ name = format!("{name} {}/{}", w.loaded, g.magazine);
+ }
+ if let Item::Armor(a) = &stack.item
+ && a.durability < a.max_durability
+ {
+ name = format!("{name} {}/{}", a.durability, a.max_durability);
+ }
+ let (price, price_colour) = match price(view, row, &stack) {
+ Price::Here(n, true) => (n.to_string(), ACCENT),
+ Price::Here(n, false) => (n.to_string(), DIM),
+ Price::About(n) => (format!("~{n}"), GUESS),
+ Price::Unknown => ("?".into(), DIM),
+ Price::Nothing => (String::new(), DIM),
+ };
+ let kg = items::kg(stack.mass());
+ let right = columns(width, &kg, &price);
+ let used = INDENT.len() + 2 + label.chars().count() + 1 + right.chars().count();
+ let room = width.saturating_sub(used);
+ let shown: String = name.chars().take(room).collect();
+ let pad = room.saturating_sub(shown.chars().count());
+ let named = match &stack.item {
+ Item::Weapon(w) => w.named,
+ Item::Armor(a) => a.named,
+ _ => false,
+ };
+ let name_style = Style::new().fg(rgb(colour));
+ let mut spans = vec![
+ Span::raw(INDENT),
+ Span::raw(glyph.to_string()).fg(rgb(colour)),
+ Span::raw(" "),
+ Span::raw(label).fg(DIM),
+ Span::styled(shown, if named { name_style.bold() } else { name_style }),
+ Span::raw(format!("{:pad$} ", "")),
+ ];
+ if weighed {
+ spans.push(Span::raw(format!("{kg:>5} ")).fg(DIM));
+ }
+ spans.push(Span::raw(format!("{price:>5} ")).fg(price_colour));
+ Line::from(spans)
+ }
+
+ fn draw_details(&self, frame: &mut Frame, area: Rect, view: &View) {
+ let picked = self.picked(view);
+ let stack = picked.and_then(|row| Self::stack(view, row));
+ let title = stack.as_ref().map_or(String::new(), |s| format!(" {} ", s.item.label()));
+ let block = Block::bordered()
+ .border_style(Style::new().fg(DIM))
+ .title(Line::from(title).fg(ACCENT).bold())
+ .padding(Padding::horizontal(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let Some(row) = picked else { return };
+ let mut lines = about(view, row, stack.as_ref());
+ let acts = Self::acts(view, row, self.count.unwrap_or(1));
+ if !acts.is_empty() {
+ lines.push(Line::default());
+ match self.menu {
+ Some(choice) => {
+ for (i, act) in acts.iter().enumerate() {
+ let line = Line::from(vec![
+ Span::raw(format!(" {} ", act.key)).fg(ACCENT).bold(),
+ Span::raw(format!(" {} ", act.what)),
+ ]);
+ let chosen = i == choice.min(acts.len() - 1);
+ lines.push(if chosen {
+ line.patch_style(Style::new().add_modifier(Modifier::REVERSED))
+ } else {
+ line
+ });
+ }
+ }
+ None => {
+ let mut spans = Vec::new();
+ for act in &acts {
+ spans.push(Span::raw(act.key.to_string()).fg(ACCENT).bold());
+ spans.push(Span::raw(format!(" {} ", act.what)).fg(DIM));
+ }
+ lines.push(Line::from(spans));
+ }
+ }
+ }
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
+ }
+
+ fn draw_foot(&self, frame: &mut Frame, area: Rect, shop: bool) {
+ let keys: Vec<Span> = if self.finding {
+ let mut spans = vec![
+ Span::raw(format!("/{}", self.filter)).fg(ACCENT),
+ Span::raw("█ ").fg(ACCENT),
+ ];
+ spans.extend(keys::line(&[("enter", "keep"), ("esc", "clear")]).spans);
+ spans
+ } else if self.menu.is_some() {
+ keys::line(&[("j/k", "choose"), ("enter", "do it"), ("esc", "back")]).spans
+ } else {
+ let mut hints = vec![
+ ("j/k", "move"),
+ ("gg/G", "top/end"),
+ ("^d/^u", "page"),
+ ("h/l", "lists"),
+ ("/", "find"),
+ ("o/O", "sort"),
+ ("enter", "act"),
+ ];
+ if shop {
+ hints.push(("b/s", "buy/sell"));
+ hints.push(("r/R", "mend"));
+ }
+ hints.push(("esc", if shop { "step away" } else { "close" }));
+ keys::line(&hints).spans
+ };
+ // What you're finding, a count you've typed and what just happened,
+ // above the keys.
+ let mut top = Vec::new();
+ if !self.filter.is_empty() && !self.finding {
+ top.push(Span::raw(format!(" /{}", self.filter)).fg(ACCENT));
+ }
+ if let Some(n) = self.count {
+ top.push(Span::raw(format!(" {n}…")).fg(ACCENT).bold());
+ }
+ if let Some(note) = &self.note {
+ top.push(Span::raw(format!(" {note}")).fg(ACCENT));
+ }
+ let lines = vec![Line::from(top), Line::from(keys)];
+ frame.render_widget(Paragraph::new(lines), area);
+ }
+}
+
+/// What's on the right of a row: what it weighs, in kg, and what it would
+/// fetch; too narrow for both, only what it'd fetch.
+fn columns(width: usize, kg: &str, price: &str) -> String {
+ if width >= NARROW { format!("{kg:>5} {price:>5} ") } else { format!("{price:>5} ") }
+}
+
+/// A heading over a kind of thing: "WEAPONS", with which column is which
+/// on the right if `units`.
+fn heading(text: &str, width: usize, units: bool) -> Line<'static> {
+ let mut spans = vec![Span::raw(format!(" {text}")).fg(HEADING).bold()];
+ if units {
+ let units = columns(width, "kg", "¤");
+ let pad = width.saturating_sub(1 + text.chars().count() + units.chars().count());
+ spans.push(Span::raw(format!("{:pad$}{units}", "")).fg(DIM));
+ }
+ Line::from(spans)
+}
+
+/// A description as a sentence: "Dense and faintly sweet."
+fn sentence(text: &str) -> String {
+ capitalise(&format!("{}.", text.trim_end_matches('.')))
+}
+
+/// All about what's picked out.
+fn about(view: &View, row: Row, stack: Option<&Stack>) -> Vec<Line<'static>> {
+ let dim = |text: String| Line::from(text).fg(DIM);
+ let Some(stack) = stack else {
+ return match row {
+ Row::You(Spot::Worn(slot)) => vec![dim(format!("Nothing on your {}.", slot.name()))],
+ _ => Vec::new(),
+ };
+ };
+ let mut lines = vec![Line::from(capitalise(&stack.name())).fg(rgb(look(&stack.item).1)).bold()];
+ match &stack.item {
+ Item::Weapon(w) => weapon_lines(view, w, row, &mut lines),
+ Item::Armor(a) => armour_lines(view, a, row, &mut lines),
+ Item::Food(f) => {
+ lines.push(Line::from(sentence(&f.description)).italic().fg(DIM));
+ lines.push(dim("Nothing gets hungry yet.".into()));
+ }
+ Item::Medpatch => {
+ lines.push(Line::from(format!(
+ "Stings, then numbs: heals {MEDPATCH_HEAL} and stops bleeding."
+ )));
+ lines.push(dim("Aboard a wreck, m uses one (or a, here).".into()));
+ }
+ Item::Artifact(a) if a.identified => {
+ lines.push(Line::from(format!("A scholar has told you what it is: {}.", a.true_name)));
+ let value = trade::artifact_value(a);
+ lines.push(dim(format!("A scholar would give you about {value} ¤ for it.")));
+ }
+ Item::Artifact(_) => {
+ lines.push(Line::from("Something strange, from a wreck."));
+ lines.push(dim("A scholar can tell you what it is, and buy it.".into()));
+ }
+ Item::Salvage(s) => {
+ lines.push(Line::from(format!("Worth about {} ¤ to a broker or a station.", s.value)));
+ }
+ }
+ if let Some(shop) = &view.shop {
+ match row {
+ Row::Beside(_) => {
+ if let Some(asking) = shop.terms.asking(&stack.item) {
+ let have = shop.scrip;
+ lines.push(if asking <= have {
+ Line::from(format!("{asking} ¤. You have {have} ¤."))
+ } else {
+ Line::from(format!("{asking} ¤: {} ¤ more than you have.", asking - have))
+ .yellow()
+ });
+ }
+ }
+ _ => {
+ if let Some(offer) = shop.terms.offer(&stack.item) {
+ lines.push(Line::from(format!("They'd give you {offer} ¤ for it.")));
+ let alone = match row {
+ Row::You(Spot::Melee) => view.gear.spare(false).is_none(),
+ Row::You(Spot::Gun) => view.gear.spare(true).is_none(),
+ _ => false,
+ };
+ if alone {
+ lines.push(dim("Have another of its sort to ready first.".into()));
+ }
+ }
+ }
+ }
+ }
+ if let Row::Aboard(_) = row {
+ lines.push(dim("In your ship's lockers.".into()));
+ }
+ let each = items::kg(stack.item.mass());
+ let mut weighs = if stack.count > 1 {
+ format!("{each} kg each, {} kg in all.", items::kg(stack.mass()))
+ } else {
+ format!("{each} kg.")
+ };
+ // Salvage and artifacts say what they're worth already.
+ if view.shop.is_none()
+ && matches!(stack.item, Item::Weapon(_) | Item::Armor(_))
+ && let Some(offer) = trade::typical_offer(&stack.item)
+ {
+ weighs.push_str(&format!(" An armourer would give you about {offer} ¤ for it."));
+ }
+ lines.push(dim(weighs));
+ lines
+}
+
+/// What a row's stack would fetch, or cost: at an armourer's, their price
+/// for what they deal in; anywhere else, or for anything else, about what a
+/// buyer would give.
+fn price(view: &View, row: Row, stack: &Stack) -> Price {
+ if let Some(shop) = &view.shop {
+ let here = match row {
+ Row::Beside(_) => {
+ shop.terms.asking(&stack.item).map(|n| Price::Here(n, n <= shop.scrip))
+ }
+ _ => shop.terms.offer(&stack.item).map(|n| Price::Here(n, true)),
+ };
+ if let Some(here) = here {
+ return here;
+ }
+ }
+ match (&stack.item, trade::typical_offer(&stack.item)) {
+ (_, Some(n)) => Price::About(n.saturating_mul(stack.count)),
+ (Item::Artifact(_), None) => Price::Unknown,
+ _ => Price::Nothing,
+ }
+}
+
+/// Which of two rows of a pile comes first, within their kind, by the
+/// view's sort. Things the sort says nothing about (no price, no
+/// durability) go last whichever way round; ties go by name.
+fn order(view: &View, a: &(Row, &Stack), b: &(Row, &Stack)) -> std::cmp::Ordering {
+ let name = |(_, s): &(Row, &Stack)| s.item.label().to_lowercase();
+ let by_name = name(a).cmp(&name(b));
+ let sort = view.sort;
+ if sort.by == SortBy::Name {
+ return if sort.reversed { by_name.reverse() } else { by_name };
+ }
+ let key = |&(row, s): &(Row, &Stack)| -> Option<f64> {
+ let grams = f64::from(s.mass());
+ match sort.by {
+ SortBy::Name => None,
+ SortBy::Weight => Some(grams),
+ SortBy::Price => price(view, row, s).amount().map(f64::from),
+ SortBy::Density => {
+ let n = price(view, row, s).amount()?;
+ (grams > 0.0).then(|| f64::from(n) / grams)
+ }
+ // Most worn first: least left, as a share of whole.
+ SortBy::Durability => match &s.item {
+ Item::Armor(a) if a.max_durability > 0 => {
+ Some(-f64::from(a.durability) / f64::from(a.max_durability))
+ }
+ _ => None,
+ },
+ SortBy::Strength => match &s.item {
+ Item::Weapon(w) => {
+ let pellets = w.gun.map_or(1, |g| g.pellets);
+ Some(f64::from(w.damage.0 + w.damage.1) / 2.0 * f64::from(pellets))
+ }
+ Item::Armor(a) => {
+ Some(f64::from(Dmg::ALL.iter().map(|&d| a.guard(d)).sum::<i32>()))
+ }
+ _ => None,
+ },
+ SortBy::Newest => match row {
+ Row::You(Spot::Pack(i)) | Row::Beside(i) | Row::Aboard(i) => Some(i as f64),
+ _ => None,
+ },
+ }
+ };
+ let first = match (key(a), key(b)) {
+ (Some(x), Some(y)) => {
+ // Highest first, unless turned round.
+ let o = y.total_cmp(&x);
+ if sort.reversed { o.reverse() } else { o }
+ }
+ (Some(_), None) => std::cmp::Ordering::Less,
+ (None, Some(_)) => std::cmp::Ordering::Greater,
+ (None, None) => std::cmp::Ordering::Equal,
+ };
+ first.then(by_name)
+}
+
+/// What the armourer you're at wants to mend `item`, if it's armour that
+/// needs it.
+fn mend_cost(view: &View, item: &Item) -> Option<u32> {
+ let shop = view.shop.as_ref()?;
+ let Item::Armor(a) = item else { return None };
+ Some(shop.terms.mending(a)).filter(|&cost| cost > 0)
+}
+
+/// How many pieces of yours want mending, on you and aboard, and what the
+/// armourer you're at wants for all of them.
+fn mend_all_cost(view: &View) -> Option<(usize, u32)> {
+ let shop = view.shop.as_ref()?;
+ let g = view.gear;
+ let packed = g.pack.iter().chain(shop.aboard).filter_map(|s| match &s.item {
+ Item::Armor(a) => Some(a),
+ _ => None,
+ });
+ let costs: Vec<u32> =
+ g.worn.values().chain(packed).map(|a| shop.terms.mending(a)).filter(|&c| c > 0).collect();
+ (!costs.is_empty()).then(|| (costs.len(), costs.iter().sum()))
+}
+
+fn armour_lines(view: &View, a: &Armor, row: Row, lines: &mut Vec<Line<'static>>) {
+ let what = capitalise(&a.description);
+ let what = match a.condition {
+ Condition::Worn => Some(format!("{what}, worn: it won't last as long.")),
+ Condition::Sound if a.named => Some(format!("{what}.")),
+ Condition::Sound => None,
+ Condition::Fine => Some(format!("{what}, finely made.")),
+ Condition::Relic => Some(format!("{what}: a relic, and stronger for it.")),
+ };
+ lines.extend(what.map(|what| Line::from(what).italic().fg(DIM)));
+ let on = match row {
+ Row::You(Spot::Worn(_)) => "You wear it",
+ _ => "Worn",
+ };
+ lines.push(Line::from(format!("{on} on your {}. Guards {}.", a.slot.name(), a.guards_text())));
+ let mut durability = capitalise(&a.durability_text());
+ if a.ruined() {
+ durability.push_str(": worn out, it guards nothing until it's mended.");
+ } else if a.thin() {
+ durability.push_str(": below half, it guards 1 less.");
+ } else {
+ durability.push('.');
+ }
+ let line = match mend_cost(view, &Item::Armor(a.clone())) {
+ Some(cost) if !matches!(row, Row::Beside(_)) => Line::from(vec![
+ Span::raw(durability),
+ Span::raw(format!(" Mending it whole: {cost} ¤.")).fg(ACCENT),
+ ]),
+ _ => Line::from(durability),
+ };
+ lines.push(match () {
+ _ if a.ruined() => line.red(),
+ _ if a.thin() => line.yellow(),
+ _ => line,
+ });
+ if !matches!(row, Row::You(Spot::Worn(_))) {
+ lines.push(versus_armour(a, view.gear.worn.get(&a.slot)));
+ }
+ if view.shop.is_none() {
+ lines.push(dim_line("Each blow it softens wears it 1. An armourer mends it."));
+ }
+}
+
+fn dim_line(text: &str) -> Line<'static> {
+ Line::from(text.to_string()).fg(DIM)
+}
+
+/// How `a` measures up against what you wear in its place, as it is now:
+/// "Against your vac-helmet: +1 cut, -1 pierce." Better in green, worse in
+/// yellow.
+fn versus_armour(a: &Armor, worn: Option<&Armor>) -> Line<'static> {
+ let Some(worn) = worn else {
+ return dim_line(&format!("You wear nothing on your {} now.", a.slot.name()));
+ };
+ let mut diffs: Vec<(i32, String)> = Vec::new();
+ for dmg in Dmg::ALL {
+ let d = a.guard(dmg) - worn.guard(dmg);
+ if d != 0 {
+ diffs.push((d, format!("{d:+} {}", dmg.name())));
+ }
+ }
+ match (a.stanching(), worn.stanching()) {
+ (true, false) => diffs.push((1, "stops bleeding".into())),
+ (false, true) => diffs.push((-1, "lets wounds bleed".into())),
+ _ => {}
+ }
+ let kg = |grams: u32| f64::from(grams) / 1000.0;
+ let heavier = kg(a.mass) - kg(worn.mass);
+ if heavier.abs() >= 0.1 {
+ diffs.push((if heavier < 0.0 { 1 } else { -1 }, format!("{heavier:+.1} kg")));
+ }
+ let mut spans = vec![Span::raw(format!("Against {}: ", worn.yours())).fg(DIM)];
+ if diffs.is_empty() {
+ spans.push(Span::raw("much the same.").fg(DIM));
+ }
+ let last = diffs.len().saturating_sub(1);
+ for (i, (sign, text)) in diffs.into_iter().enumerate() {
+ let span = Span::raw(text);
+ spans.push(match sign {
+ s if s > 0 => span.green(),
+ s if s < 0 => span.yellow(),
+ _ => span,
+ });
+ spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM));
+ }
+ Line::from(spans)
+}
+
+fn weapon_lines(view: &View, w: &Weapon, row: Row, lines: &mut Vec<Line<'static>>) {
+ let what = match w.condition {
+ _ if w.kit => {
+ "Your own. Lose everything else aboard a wreck and you keep this.".to_string()
+ }
+ Condition::Worn => format!("{}, worn: it hits softer.", capitalise(&w.description)),
+ Condition::Sound => format!("{}.", capitalise(&w.description)),
+ Condition::Fine => format!("{}, finely made.", capitalise(&w.description)),
+ Condition::Relic => {
+ format!("{}: a relic, and stronger for it.", capitalise(&w.description))
+ }
+ };
+ lines.push(Line::from(what).italic().fg(DIM));
+ match row {
+ Row::You(Spot::Melee) => lines.push(Line::from("Ready: you hit things with it.")),
+ Row::You(Spot::Gun) => lines.push(Line::from("Ready: you fire it.")),
+ _ => {}
+ }
+ lines.extend(w.stats().into_iter().map(Line::from));
+ if !matches!(row, Row::You(Spot::Melee | Spot::Gun)) {
+ let ready = if w.is_gun() { &view.gear.gun } else { &view.gear.melee };
+ lines.push(versus(w, ready));
+ }
+ lines.push(Line::from(w.dmg.about()).fg(DIM));
+ let against = |pick: fn(Affinity) -> bool| {
+ let names: Vec<&str> = MONSTERS
+ .iter()
+ .filter(|k| view.known.contains(&(k.name, w.dmg)) && pick(k.affinity(w.dmg)))
+ .map(|k| k.name)
+ .collect();
+ names.join(", ")
+ };
+ let good = against(|a| a == Affinity::Weak);
+ if !good.is_empty() {
+ lines.push(Line::from(vec!["good against ".green(), Span::from(good)]));
+ }
+ let poor = against(|a| matches!(a, Affinity::Resists | Affinity::Immune));
+ if !poor.is_empty() {
+ lines.push(Line::from(vec!["poor against ".yellow(), Span::from(poor)]));
+ }
+}
+
+/// How `w` measures up against the weapon you have ready in its place:
+/// "Against your needler: +3 damage, +5 range." Better in green, worse in
+/// yellow.
+fn versus(w: &Weapon, ready: &Weapon) -> Line<'static> {
+ let damage = |w: &Weapon| {
+ let (lo, hi) = w.damage;
+ f64::from(lo + hi) / 2.0 * f64::from(w.gun.map_or(1, |g| g.pellets))
+ };
+ let pct = |p: f64| (p * 100.0).round() as i32;
+ let mut diffs: Vec<(f64, String)> = Vec::new();
+ let d = damage(w) - damage(ready);
+ if d.abs() >= 0.25 {
+ let d = (d * 2.0).round() / 2.0;
+ let shown = if d.fract() == 0.0 { format!("{d:+.0}") } else { format!("{d:+.1}") };
+ diffs.push((d, format!("{shown} damage")));
+ }
+ match (w.gun, ready.gun) {
+ (Some(g), Some(r)) => {
+ if g.range != r.range {
+ diffs
+ .push((f64::from(g.range - r.range), format!("{:+} range", g.range - r.range)));
+ }
+ if g.magazine != r.magazine {
+ let n = i64::from(g.magazine) - i64::from(r.magazine);
+ let rounds = if n.abs() == 1 { "round" } else { "rounds" };
+ diffs.push((n as f64, format!("{n:+} {rounds}")));
+ }
+ }
+ _ => {
+ let hit = pct(w.accuracy) - pct(ready.accuracy);
+ if hit != 0 {
+ diffs.push((f64::from(hit), format!("{hit:+}% to hit")));
+ }
+ }
+ }
+ let crit = pct(w.crit) - pct(ready.crit);
+ if crit != 0 {
+ diffs.push((f64::from(crit), format!("{crit:+}% crit")));
+ }
+ if w.dmg != ready.dmg {
+ diffs.push((0.0, format!("{} not {}", w.dmg.name(), ready.dmg.name())));
+ }
+ let mut spans = vec![Span::raw(format!("Against {}: ", ready.yours())).fg(DIM)];
+ if diffs.is_empty() {
+ spans.push(Span::raw("much the same.").fg(DIM));
+ }
+ let last = diffs.len().saturating_sub(1);
+ for (i, (sign, text)) in diffs.into_iter().enumerate() {
+ let span = Span::raw(text);
+ spans.push(match sign {
+ s if s > 0.0 => span.green(),
+ s if s < 0.0 => span.yellow(),
+ _ => span,
+ });
+ spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM));
+ }
+ Line::from(spans)
+}
+
+fn capitalise(s: &str) -> String {
+ let mut chars = s.chars();
+ chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect())
+}
+
+/// Carries out a command that takes no time, outside a wreck: on what you
+/// carry, the lockers if they're beside you, and the pile on the floor
+/// where you stand. Says how it went.
+pub fn carry_out(
+ command: Command,
+ gear: &mut Gear,
+ lockers: Option<&mut Vec<Stack>>,
+ floor: &mut Vec<Stack>,
+ beside: Option<Beside>,
+) -> String {
+ let said = |result: Result<String, String>| result.unwrap_or_else(|e| e);
+ match command {
+ Command::Ready(i) => said(gear.ready(i)),
+ Command::Wear(i) => said(gear.wear(i)),
+ Command::TakeOff(slot) => said(gear.take_off(slot)),
+ Command::Eat(i) => said(gear.eat(i)),
+ Command::UseMedpatch => "You're not hurt.".into(),
+ Command::Drop(spot, n) => match gear.remove(spot, n) {
+ Ok(stack) => {
+ let said = format!("You put down {}.", stack.name());
+ items::stow(floor, stack);
+ said
+ }
+ Err(said) => said,
+ },
+ Command::Stow(spot, n) => {
+ let Some(lockers) = lockers else { return "There are no lockers here.".into() };
+ let Some(stack) = gear.get(spot) else { return "There's nothing there.".into() };
+ let n = n.min(stack.count);
+ let room = LOCKER_CAPACITY.saturating_sub(items::pile_mass(lockers));
+ if stack.item.mass().saturating_mul(n) > room {
+ return "The lockers are full.".into();
+ }
+ match gear.remove(spot, n) {
+ Ok(stack) => {
+ let said = format!("You stow {} in the lockers.", stack.name());
+ items::stow(lockers, stack);
+ said
+ }
+ Err(said) => said,
+ }
+ }
+ Command::Fetch(i, n) => {
+ let (pile, from) = match (beside, lockers) {
+ (Some(Beside::Lockers), Some(lockers)) => (lockers, " from the lockers"),
+ _ => (floor, ""),
+ };
+ let Some(stack) = pile.get(i) else { return "There's nothing there.".into() };
+ let n = n.min(stack.count).min(gear.fits(&stack.item));
+ if n == 0 {
+ let room = items::kg(gear.room());
+ return format!("You've no room for {}: only {room} kg to spare.", stack.name());
+ }
+ let stack = items::take(pile, i, n);
+ let said = format!("You take {}{from}.", stack.name());
+ gear.add(stack);
+ said
+ }
+ Command::Buy(_) | Command::Sell(_) | Command::SellAboard(_) => {
+ "There's no one here to trade with.".into()
+ }
+ Command::Mend(_) | Command::MendAboard(_) | Command::MendAll => {
+ "There's no armourer here to mend it.".into()
+ }
+ }
+}
+
+/// Takes the stacks lying at `at` out of a list of piles, to work on.
+pub fn gather<K: PartialEq + Copy>(piles: &mut Vec<(K, Stack)>, at: K) -> Vec<Stack> {
+ let mut here = Vec::new();
+ let mut i = 0;
+ while i < piles.len() {
+ if piles[i].0 == at {
+ here.push(piles.remove(i).1);
+ } else {
+ i += 1;
+ }
+ }
+ here
+}
+
+/// Puts back what `gather` took, after.
+pub fn scatter<K: Copy>(piles: &mut Vec<(K, Stack)>, at: K, here: Vec<Stack>) {
+ piles.extend(here.into_iter().map(|s| (at, s)));
+}
+
+/// What's lying at `at` in a list of piles, for showing.
+pub fn lying<K: PartialEq>(piles: &[(K, Stack)], at: K) -> Vec<Stack> {
+ piles.iter().filter(|(k, _)| *k == at).map(|(_, s)| s.clone()).collect()
+}
+
+/// What to say when you step onto things: "There's a cleaver here (g)."
+pub fn underfoot(here: &[Stack]) -> Option<String> {
+ match here {
+ [] => None,
+ [one] => Some(format!("There's {} here (g picks it up).", one.name())),
+ _ => Some(format!("There are {} things here (g to look).", here.len())),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dungeon::{Pos, arms};
+ use crate::names::Culture;
+ use crate::seed;
+
+ fn press(inv: &mut Inventory, view: &View, code: KeyCode) -> Reply {
+ inv.key(KeyEvent::new(code, KeyModifiers::NONE), view)
+ }
+
+ fn typed(inv: &mut Inventory, view: &View, keys: &str) -> Vec<Reply> {
+ keys.chars().map(|c| press(inv, view, KeyCode::Char(c))).collect()
+ }
+
+ fn gear() -> Gear {
+ let mut gear = Gear::default();
+ gear.add(Stack { item: Item::Medpatch, count: 5 });
+ let mut rng = seed::rng(2);
+ gear.add(Stack::one(Item::Weapon(arms::find(&mut rng, arms::Source::Crew, false))));
+ gear
+ }
+
+ #[test]
+ fn vim_keys_move_and_counts_count() {
+ let gear = gear();
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ // Worn ×4, ready ×2, then the pack: the torch is ready, so the pack
+ // has the found weapon and the medpatches.
+ assert_eq!(inv.picks(&view, false).len(), 8);
+ typed(&mut inv, &view, "G");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(0))), "medpatches come last");
+ typed(&mut inv, &view, "gg");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Worn(Slot::Head))));
+ typed(&mut inv, &view, "5j");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Gun)));
+ typed(&mut inv, &view, "G");
+ let replies = typed(&mut inv, &view, "3d");
+ assert_eq!(replies[1], Reply::Do(Command::Drop(Spot::Pack(0), 3)));
+ assert_eq!(typed(&mut inv, &view, "D")[0], Reply::Do(Command::Drop(Spot::Pack(0), 5)));
+ assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Stay, "no use for one here");
+ assert_eq!(press(&mut inv, &view, KeyCode::Esc), Reply::Close);
+ }
+
+ #[test]
+ fn at_an_armourers_you_buy_off_the_rack_and_sell_what_you_can_spare() {
+ // The torch and rivet gun ready, a found weapon and medpatches packed.
+ let gear = gear();
+ let found = gear.pack.iter().position(|s| matches!(s.item, Item::Weapon(_))).unwrap();
+ let mut rng = seed::rng(4);
+ let rack: Vec<Stack> = (0..3)
+ .map(|_| Stack::one(Item::Weapon(arms::stocked(&mut rng, Culture::Cant, false))))
+ .collect();
+ let aboard = [Stack::one(Item::Medpatch), rack[0].clone()];
+ let terms = trade::armoury_terms(&crate::space::system::generate(1), 0);
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: Some((Beside::Rack, &rack)),
+ known: &known,
+ hurt: false,
+ shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }),
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::beside();
+ let Some(Row::Beside(i)) = inv.picked(&view) else { panic!("starts on the rack") };
+ assert_eq!(typed(&mut inv, &view, "b")[0], Reply::Do(Command::Buy(i)));
+ assert_eq!(typed(&mut inv, &view, "m")[0], Reply::Stay, "you can't just take it");
+ press(&mut inv, &view, KeyCode::Char('h'));
+ // Only what they'd buy from your lockers, under its own heading.
+ let rows = inv.rows(&view, false);
+ let heading = rows.iter().position(|r| *r == Row::Heading("IN YOUR SHIP")).unwrap();
+ assert_eq!(rows[heading + 1..], [Row::Aboard(1)]);
+ typed(&mut inv, &view, "G");
+ assert_eq!(typed(&mut inv, &view, "s")[0], Reply::Do(Command::SellAboard(1)));
+ let acts = |row| Inventory::acts(&view, row, 1);
+ let sells = |row| acts(row).iter().any(|a| a.key == 's');
+ assert!(sells(Row::You(Spot::Pack(found))));
+ let Item::Weapon(w) = &gear.pack[found].item else { unreachable!() };
+ let (spared, alone) =
+ if w.is_gun() { (Spot::Gun, Spot::Melee) } else { (Spot::Melee, Spot::Gun) };
+ assert!(sells(Row::You(spared)), "ready, with a spare to take its place");
+ assert!(!sells(Row::You(alone)), "not with nothing else to fight with");
+ assert!(!sells(Row::You(Spot::Pack(1 - found))), "they don't buy medpatches");
+ // What they'd pay here, beside what it weighs.
+ let item = &gear.pack[found].item;
+ let price = terms.offer(item).unwrap();
+ let line = inv.row_line(&view, Row::You(Spot::Pack(found)), 40).to_string();
+ let kg = items::kg(item.mass());
+ assert!(line.ends_with(&format!("{kg:>5} {price:>5} ")), "{line}");
+ assert!(line.starts_with(" ) "), "indented under its heading: {line}");
+ }
+
+ #[test]
+ fn rows_show_what_things_weigh_and_about_what_theyd_fetch() {
+ let mut gear = gear();
+ let mut rng = seed::rng(3);
+ let scrap = items::Salvage { title: "hull plate".into(), value: 20, mass: 12_000 };
+ gear.add(Stack { item: Item::Salvage(scrap), count: 3 });
+ gear.add(Stack::one(Item::Artifact(crate::names::artifact(&mut rng))));
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let inv = Inventory::new();
+ let line = |row| inv.row_line(&view, row, 50).to_string();
+ let at = |pick: fn(&Item) -> bool| {
+ Row::You(Spot::Pack(gear.pack.iter().position(|s| pick(&s.item)).unwrap()))
+ };
+ // A whole stack's worth, and its weight.
+ assert!(line(at(|i| matches!(i, Item::Salvage(_)))).ends_with(" 36.0 ~60 "));
+ assert!(line(at(|i| matches!(i, Item::Artifact(_)))).ends_with(" ? "), "not named yet");
+ assert!(line(at(|i| *i == Item::Medpatch)).ends_with(" "), "no one buys them");
+ let weapon = at(|i| matches!(i, Item::Weapon(_)));
+ let Row::You(Spot::Pack(w)) = weapon else { unreachable!() };
+ let about = trade::typical_offer(&gear.pack[w].item).unwrap();
+ assert!(line(weapon).ends_with(&format!("~{about} ")));
+ // The headings say which column is which.
+ let heading = inv.row_line(&view, Row::Heading("WEAPONS"), 50).to_string();
+ assert!(heading.starts_with(" WEAPONS") && heading.ends_with(" kg ¤ "), "{heading}");
+ // Too narrow for both, just the price.
+ assert!(inv.row_line(&view, weapon, 24).to_string().ends_with(&format!("~{about} ")));
+ assert!(!inv.row_line(&view, weapon, 24).to_string().contains(" kg"));
+ }
+
+ #[test]
+ fn o_sorts_both_lists_within_each_kind_and_o_turns_it_round() {
+ let mut gear = Gear::default();
+ let scrap = |title: &str, value, mass| {
+ Stack::one(Item::Salvage(items::Salvage { title: title.into(), value, mass }))
+ };
+ gear.add(scrap("brass fitting", 30, 1000));
+ gear.add(scrap("hull plate", 20, 12_000));
+ gear.add(scrap("coil", 50, 4000));
+ gear.add(Stack { item: Item::Medpatch, count: 2 });
+ let floor = [scrap("valve", 5, 500), scrap("anchor", 90, 30_000)];
+ let known = BTreeSet::new();
+ let mut view = View {
+ gear: &gear,
+ beside: Some((Beside::Floor("on the floor"), &floor)),
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ let salvage = |inv: &Inventory, view: &View, beside: bool| -> Vec<String> {
+ inv.rows(view, beside)
+ .into_iter()
+ .filter_map(|r| Inventory::stack(view, r))
+ .filter(|s| matches!(s.item, Item::Salvage(_)))
+ .map(|s| s.item.label())
+ .collect()
+ };
+ assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]);
+ // On the coil, then sorted heaviest first: the cursor stays on it.
+ inv.cursor[0] =
+ inv.picks(&view, false).iter().position(|r| *r == Row::You(Spot::Pack(2))).unwrap();
+ let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('o')) else { panic!() };
+ assert_eq!(sort, Sort { by: SortBy::Weight, reversed: false });
+ view.sort = sort;
+ assert_eq!(salvage(&inv, &view, false), ["hull plate", "coil", "brass fitting"]);
+ assert_eq!(salvage(&inv, &view, true), ["anchor", "valve"], "the floor too");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(2))));
+ let rows = inv.rows(&view, false);
+ let medical = rows.iter().position(|r| *r == Row::Heading("MEDICAL")).unwrap();
+ let salvage_at = rows.iter().position(|r| *r == Row::Heading("SALVAGE")).unwrap();
+ assert!(medical < salvage_at, "still grouped by kind");
+
+ let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('O')) else { panic!() };
+ view.sort = sort;
+ assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]);
+ assert_eq!(sort.says(), "lightest first");
+
+ view.sort = Sort { by: SortBy::Density, reversed: false };
+ assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]);
+ view.sort = Sort { by: SortBy::Newest, reversed: false };
+ assert_eq!(salvage(&inv, &view, false), ["coil", "hull plate", "brass fitting"]);
+ // Round they go, back to names.
+ let mut by = SortBy::Name;
+ for _ in 0..SortBy::ALL.len() {
+ by = by.next();
+ }
+ assert_eq!(by, SortBy::Name);
+ }
+
+ #[test]
+ fn worn_armour_sorts_first_by_durability_and_the_rest_after() {
+ let mut rng = seed::rng(9);
+ let mut gear = Gear::default();
+ for left in [10, 2, 6] {
+ let mut a = crate::dungeon::armour::stocked(&mut rng, Culture::Cant, false);
+ (a.title, a.max_durability, a.durability) = (format!("piece {left}"), 10, left);
+ gear.add(Stack::one(Item::Armor(a)));
+ }
+ let known = BTreeSet::new();
+ let mut view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort { by: SortBy::Durability, reversed: false },
+ };
+ let inv = Inventory::new();
+ let armour = |view: &View| -> Vec<String> {
+ inv.rows(view, false)
+ .into_iter()
+ .filter_map(|r| match r {
+ Row::You(Spot::Pack(_)) => Inventory::stack(view, r),
+ _ => None,
+ })
+ .map(|s| s.item.label())
+ .collect()
+ };
+ assert_eq!(armour(&view), ["piece 2", "piece 6", "piece 10"]);
+ view.sort.reversed = true;
+ assert_eq!(armour(&view), ["piece 10", "piece 6", "piece 2"]);
+ }
+
+ #[test]
+ fn at_an_armourers_worn_armour_can_be_mended() {
+ let mut rng = seed::rng(6);
+ let mut piece = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false);
+ let mut gear = Gear::default();
+ piece.durability = 1;
+ gear.worn.insert(piece.slot, piece.clone());
+ let mut spare = piece.clone();
+ spare.slot = piece.slot;
+ gear.add(Stack::one(Item::Armor(spare)));
+ let whole = {
+ let mut p = piece.clone();
+ p.repair();
+ p
+ };
+ let aboard = [Stack::one(Item::Armor(whole))];
+ let terms = trade::armoury_terms(&crate::space::system::generate(1), 0);
+ let known = BTreeSet::new();
+ let rack = Vec::new();
+ let view = View {
+ gear: &gear,
+ beside: Some((Beside::Rack, &rack)),
+ known: &known,
+ hurt: false,
+ shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }),
+ sort: Sort::default(),
+ };
+ let cost = terms.mending(&piece);
+ let acts = Inventory::acts(&view, Row::You(Spot::Worn(piece.slot)), 1);
+ let mend = acts.iter().find(|a| a.key == 'r').expect("an act to mend it");
+ assert_eq!(mend.what, format!("mend it for {cost} ¤"));
+ let all = acts.iter().find(|a| a.key == 'R').expect("and to mend them all");
+ assert_eq!(all.what, format!("mend all 2 for {} ¤", cost * 2));
+ let whole_acts = Inventory::acts(&view, Row::Aboard(0), 1);
+ assert!(!whole_acts.iter().any(|a| a.key == 'r'), "nothing to mend in a whole one");
+ // R mends everything from wherever the cursor is.
+ let mut inv = Inventory::beside();
+ assert_eq!(typed(&mut inv, &view, "R")[0], Reply::Do(Command::MendAll));
+ // The list shows how worn it is.
+ let line = inv.row_line(&view, Row::You(Spot::Worn(piece.slot)), 60).to_string();
+ assert!(line.contains(&format!(" 1/{}", piece.max_durability)), "{line}");
+ }
+
+ #[test]
+ fn armour_says_how_it_measures_up_to_what_you_wear() {
+ let mut rng = seed::rng(6);
+ let mut worn = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false);
+ worn.title = "vac-helmet".into();
+ worn.guards = [(Dmg::Pierce, 1), (Dmg::Blunt, 1)].into_iter().collect();
+ worn.mass = 1800;
+ let mut other = worn.clone();
+ other.guards = [(Dmg::Cut, 1), (Dmg::Blunt, 1)].into_iter().collect();
+ other.mass = 1300;
+ assert_eq!(
+ versus_armour(&other, Some(&worn)).to_string(),
+ "Against your vac-helmet: +1 cut, -1 pierce, -0.5 kg."
+ );
+ assert!(versus_armour(&other, None).to_string().starts_with("You wear nothing on your"));
+ }
+
+ #[test]
+ fn a_weapon_says_how_it_measures_up_to_the_one_you_have_ready() {
+ let torch = arms::cutting_torch();
+ let mut better = torch.clone();
+ better.damage = (4, 7);
+ better.accuracy = 0.75;
+ assert_eq!(
+ versus(&better, &torch).to_string(),
+ "Against your cutting torch: +2 damage, -5% to hit."
+ );
+ assert_eq!(
+ versus(&torch, &torch).to_string(),
+ "Against your cutting torch: much the same."
+ );
+ let gun = arms::rivet_gun();
+ let mut longer = gun.clone();
+ longer.gun.as_mut().unwrap().range += 4;
+ longer.dmg = Dmg::Shock;
+ assert_eq!(
+ versus(&longer, &gun).to_string(),
+ "Against your rivet gun: +4 range, shock not pierce."
+ );
+ }
+
+ #[test]
+ fn finding_narrows_the_list() {
+ let gear = gear();
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: true,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ press(&mut inv, &view, KeyCode::Char('/'));
+ typed(&mut inv, &view, "medp");
+ press(&mut inv, &view, KeyCode::Enter);
+ assert_eq!(inv.picks(&view, false), [Row::You(Spot::Pack(0))]);
+ assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Do(Command::UseMedpatch));
+ press(&mut inv, &view, KeyCode::Esc);
+ assert_eq!(inv.picks(&view, false).len(), 8, "esc clears it");
+ }
+
+ #[test]
+ fn the_menu_lists_what_you_can_do() {
+ let gear = gear();
+ let known = BTreeSet::new();
+ let lockers = vec![Stack { item: Item::Medpatch, count: 2 }];
+ let view = View {
+ gear: &gear,
+ beside: Some((Beside::Lockers, &lockers)),
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ typed(&mut inv, &view, "G");
+ press(&mut inv, &view, KeyCode::Enter);
+ assert_eq!(inv.menu, Some(0));
+ typed(&mut inv, &view, "jj");
+ // drop one, drop them all, stow one, stow them all.
+ assert_eq!(
+ press(&mut inv, &view, KeyCode::Enter),
+ Reply::Do(Command::Stow(Spot::Pack(0), 1))
+ );
+ typed(&mut inv, &view, "l");
+ assert_eq!(typed(&mut inv, &view, "M")[0], Reply::Do(Command::Fetch(0, 2)));
+ }
+
+ #[test]
+ fn carrying_out_moves_things_about() {
+ let mut gear = gear();
+ let mut lockers = Vec::new();
+ let mut floor = Vec::new();
+ let lockers_beside = Some(Beside::Lockers);
+ let said = carry_out(
+ Command::Stow(Spot::Pack(0), 2),
+ &mut gear,
+ Some(&mut lockers),
+ &mut floor,
+ lockers_beside,
+ );
+ assert_eq!(said, "You stow 2 medpatches in the lockers.");
+ assert_eq!((gear.medpatches(), lockers[0].count), (3, 2));
+ carry_out(Command::Fetch(0, 9), &mut gear, Some(&mut lockers), &mut floor, lockers_beside);
+ assert_eq!((gear.medpatches(), lockers.len()), (5, 0));
+ // Your own torch can go, and the blade in your pack takes its place;
+ // but not the blade too, with nothing left to hit things with.
+ let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None);
+ assert_eq!(said, "You put down your cutting torch.");
+ assert!(!gear.melee.kit);
+ let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None);
+ assert!(said.contains("nothing to hit things with"), "{said}");
+ assert_eq!(floor.len(), 1);
+ let said = carry_out(
+ Command::Fetch(0, 1),
+ &mut gear,
+ None,
+ &mut floor,
+ Some(Beside::Floor("on the deck")),
+ );
+ assert!(said.starts_with("You take"), "{said}");
+ assert!(floor.is_empty());
+ }
+
+ #[test]
+ fn piles_gather_and_scatter() {
+ let mut piles = vec![
+ (Pos::new(1, 1), Stack::one(Item::Medpatch)),
+ (Pos::new(2, 2), Stack::one(Item::Medpatch)),
+ (Pos::new(1, 1), Stack { item: Item::Medpatch, count: 3 }),
+ ];
+ let here = gather(&mut piles, Pos::new(1, 1));
+ assert_eq!((here.len(), piles.len()), (2, 1));
+ scatter(&mut piles, Pos::new(1, 1), here);
+ assert_eq!(lying(&piles, Pos::new(1, 1)).len(), 2);
+ assert!(underfoot(&lying(&piles, Pos::new(2, 2))).unwrap().contains("a medpatch"));
+ }
+}