summaryrefslogtreecommitdiff
path: root/src/dungeon/armour.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/dungeon/armour.rs')
-rw-r--r--src/dungeon/armour.rs526
1 files changed, 526 insertions, 0 deletions
diff --git a/src/dungeon/armour.rs b/src/dungeon/armour.rs
new file mode 100644
index 0000000..80357ea
--- /dev/null
+++ b/src/dungeon/armour.rs
@@ -0,0 +1,526 @@
+//! Armour: the kinds there are, the pieces you find aboard wrecks and buy
+//! at an armourer's, what they guard against, and what they're worth.
+//!
+//! Each piece guards against certain kinds of damage, taking that much off
+//! a blow of that kind (a blow always does at least 1). Every blow a piece
+//! softens wears it down a little: below half its durability it guards 1
+//! less against everything, and worn out it guards nothing at all, though
+//! it stays on you. An armourer mends it, for more the more worn it is and
+//! the finer.
+//!
+//! Like a weapon, a piece is a kind rolled up with a condition: worn ones
+//! don't last as long, fine ones carry an affix ("insulated",
+//! "reinforced"), and the rare relic carries two and a name of its own.
+
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+use serde::{Deserialize, Serialize};
+
+use super::arms::{self, Condition, Dmg, Source};
+use crate::items::Slot;
+use crate::names::{self, Culture, pick};
+use crate::seed::Rng;
+
+/// A piece of armour.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Armor {
+ /// What it's called, without an article: "insulated vac-helmet", or for
+ /// a relic its own name, "the Iron Vow".
+ pub title: String,
+ /// What it is: "a fine insulated vac-helmet". For a relic, the only
+ /// place that says what kind of thing it is.
+ pub description: String,
+ pub slot: Slot,
+ /// What it takes off a blow of each kind, while it's in good repair.
+ #[serde(default)]
+ pub guards: BTreeMap<Dmg, i32>,
+ /// Whether it keeps a wound closed: a cut that lands doesn't bleed.
+ #[serde(default)]
+ pub stanches: bool,
+ /// What's left of it: one less for every blow it softens.
+ #[serde(default)]
+ pub durability: u32,
+ /// What it has when it's whole.
+ #[serde(default)]
+ pub max_durability: u32,
+ #[serde(default = "sound")]
+ pub condition: Condition,
+ /// A relic with its own name.
+ #[serde(default)]
+ pub named: bool,
+ /// Gloves and boots: "a pair of mag-boots".
+ #[serde(default)]
+ pub pair: bool,
+ /// What it weighs, in grams.
+ pub mass: u32,
+}
+
+fn sound() -> Condition {
+ Condition::Sound
+}
+
+impl Armor {
+ /// "a vac-helmet", "a pair of mag-boots", "the Iron Vow".
+ pub fn name(&self) -> String {
+ if self.named {
+ self.title.clone()
+ } else if self.pair {
+ format!("a pair of {}", self.title)
+ } else {
+ names::a(&self.title)
+ }
+ }
+
+ /// "your vac-helmet", "the Iron Vow": the subject of a sentence.
+ pub fn yours(&self) -> String {
+ if self.named { self.title.clone() } else { format!("your {}", self.title) }
+ }
+
+ /// "the vac-helmet", "the Iron Vow".
+ pub fn the(&self) -> String {
+ if self.named { self.title.clone() } else { format!("the {}", self.title) }
+ }
+
+ /// "is", or "are" for a pair of something.
+ pub fn is(&self) -> &'static str {
+ if self.pair && !self.named { "are" } else { "is" }
+ }
+
+ /// Below half its durability: it guards 1 less against everything.
+ pub fn thin(&self) -> bool {
+ self.durability * 2 < self.max_durability
+ }
+
+ /// Worn out: it guards nothing until it's mended.
+ pub fn ruined(&self) -> bool {
+ self.durability == 0
+ }
+
+ /// What it takes off a blow of `dmg` as it is now.
+ pub fn guard(&self, dmg: Dmg) -> i32 {
+ let whole = self.guards.get(&dmg).copied().unwrap_or(0);
+ match () {
+ _ if self.ruined() => 0,
+ _ if self.thin() => (whole - 1).max(0),
+ _ => whole,
+ }
+ }
+
+ /// Whether it keeps a wound closed as it is now.
+ pub fn stanching(&self) -> bool {
+ self.stanches && !self.ruined()
+ }
+
+ /// What it guards against when it's whole: "pierce 1 · blunt 1 · stops
+ /// bleeding".
+ pub fn guards_text(&self) -> String {
+ let mut parts: Vec<String> = self
+ .guards
+ .iter()
+ .filter(|&(_, &g)| g > 0)
+ .map(|(d, g)| format!("{} {g}", d.name()))
+ .collect();
+ if self.stanches {
+ parts.push("stops bleeding".into());
+ }
+ if parts.is_empty() {
+ return "guards nothing".into();
+ }
+ parts.join(" · ")
+ }
+
+ /// "durability 12/30".
+ pub fn durability_text(&self) -> String {
+ format!("durability {}/{}", self.durability, self.max_durability)
+ }
+
+ /// One blow softened: it wears a little. Says so when that leaves it
+ /// thin or ruined.
+ pub fn wear_down(&mut self) -> Option<String> {
+ let (thin, ruined) = (self.thin(), self.ruined());
+ self.durability = self.durability.saturating_sub(1);
+ let who = capitalise(&self.yours());
+ if self.ruined() && !ruined {
+ Some(format!("{who} {} worn out: it guards nothing until it's mended.", self.is()))
+ } else if self.thin() && !thin {
+ Some(format!("{who} {} wearing thin: it guards 1 less.", self.is()))
+ } else {
+ None
+ }
+ }
+
+ /// Makes it whole again.
+ pub fn repair(&mut self) {
+ self.durability = self.max_durability;
+ }
+
+ /// Puts right whatever a save file got wrong: numbers that make sense,
+ /// and durability for a piece saved before it had any.
+ pub fn mend(&mut self) {
+ if self.max_durability == 0 {
+ self.max_durability = 20;
+ self.durability = 20;
+ }
+ self.durability = self.durability.min(self.max_durability);
+ self.guards.retain(|_, g| *g > 0);
+ for g in self.guards.values_mut() {
+ *g = (*g).min(9);
+ }
+ }
+}
+
+fn capitalise(s: &str) -> String {
+ let mut chars = s.chars();
+ chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect())
+}
+
+struct Kind {
+ /// What it might be called; one is picked for each piece found.
+ nouns: &'static [&'static str],
+ slot: Slot,
+ /// Gloves and boots.
+ pair: bool,
+ guards: &'static [(Dmg, i32)],
+ stanches: bool,
+ durability: u32,
+ /// What one weighs, in grams.
+ mass: u32,
+ /// How common it is, relative to the others.
+ weight: u32,
+ /// Where it's most likely to turn up.
+ found: &'static [Source],
+ /// Whose armourers stock it most.
+ people: Option<Culture>,
+}
+
+use Dmg::{Blunt, Burn, Cut, Hymn, Pierce, Shock};
+use Slot::{Body, Feet, Hands, Head};
+use Source::{Armoury, Crew, Rites, Tools};
+
+const QAARI: Option<Culture> = Some(Culture::Qaari);
+const CANT: Option<Culture> = Some(Culture::Cant);
+const HOUSES: Option<Culture> = Some(Culture::Houses);
+const DRIFT: Option<Culture> = Some(Culture::Drift);
+
+#[rustfmt::skip]
+const KINDS: &[Kind] = &[
+ // Heads.
+ Kind { nouns: &["vac-helmet", "pressure helm", "bubble-helm"], slot: Head, pair: false, guards: &[(Pierce, 1), (Blunt, 1)], stanches: false, durability: 24, mass: 1800, weight: 4, found: &[Tools, Armoury, Crew], people: DRIFT },
+ Kind { nouns: &["choir-hood", "cantor's cowl", "psalm-veil"], slot: Head, pair: false, guards: &[(Hymn, 2)], stanches: false, durability: 16, mass: 400, weight: 2, found: &[Rites], people: HOUSES },
+ Kind { nouns: &["dust-veil", "head-wrap", "sun-hood"], slot: Head, pair: false, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 300, weight: 2, found: &[Crew], people: QAARI },
+ Kind { nouns: &["lineman's hood", "rubber cowl", "dielectric cap"], slot: Head, pair: false, guards: &[(Shock, 2)], stanches: false, durability: 18, mass: 700, weight: 2, found: &[Tools], people: CANT },
+ Kind { nouns: &["crested helm", "sallet", "visored helm"], slot: Head, pair: false, guards: &[(Cut, 1), (Blunt, 1), (Hymn, 1)], stanches: false, durability: 30, mass: 2200, weight: 1, found: &[Armoury], people: HOUSES },
+ // Bodies.
+ Kind { nouns: &["flight-jacket", "deck coat", "quilted jerkin"], slot: Body, pair: false, guards: &[(Cut, 2), (Blunt, 1)], stanches: true, durability: 26, mass: 2500, weight: 4, found: &[Crew], people: None },
+ Kind { nouns: &["ceramic vest", "flak vest", "plate carrier"], slot: Body, pair: false, guards: &[(Pierce, 1), (Cut, 1), (Blunt, 1)], stanches: false, durability: 30, mass: 5500, weight: 3, found: &[Armoury], people: DRIFT },
+ Kind { nouns: &["hardsuit", "pressure suit", "vac-suit"], slot: Body, pair: false, guards: &[(Pierce, 2), (Blunt, 1)], stanches: false, durability: 34, mass: 7500, weight: 2, found: &[Tools, Armoury], people: DRIFT },
+ Kind { nouns: &["lineman's apron", "dielectric coat", "rubber smock"], slot: Body, pair: false, guards: &[(Shock, 2), (Burn, 1)], stanches: false, durability: 26, mass: 4000, weight: 2, found: &[Tools], people: CANT },
+ Kind { nouns: &["coil-plate", "grounded cuirass", "dielectric plate"], slot: Body, pair: false, guards: &[(Shock, 2), (Cut, 1), (Blunt, 1)], stanches: false, durability: 36, mass: 7000, weight: 1, found: &[Armoury, Tools], people: CANT },
+ Kind { nouns: &["cuirass", "breastplate", "half-plate"], slot: Body, pair: false, guards: &[(Cut, 2), (Blunt, 2)], stanches: false, durability: 40, mass: 8000, weight: 1, found: &[Armoury], people: HOUSES },
+ Kind { nouns: &["surplice", "chasuble", "vestment"], slot: Body, pair: false, guards: &[(Hymn, 2)], stanches: false, durability: 18, mass: 1200, weight: 2, found: &[Rites], people: HOUSES },
+ Kind { nouns: &["dust-cloak", "layered robe", "sun-wrap"], slot: Body, pair: false, guards: &[(Burn, 2), (Cut, 1)], stanches: false, durability: 22, mass: 1800, weight: 2, found: &[Crew], people: QAARI },
+ // Hands.
+ Kind { nouns: &["lineman's gauntlets", "rubber gauntlets", "dielectric gloves"], slot: Hands, pair: true, guards: &[(Shock, 2)], stanches: false, durability: 18, mass: 600, weight: 3, found: &[Tools], people: CANT },
+ Kind { nouns: &["work gloves", "rigger's gloves", "welding gloves"], slot: Hands, pair: true, guards: &[(Cut, 1), (Burn, 1)], stanches: false, durability: 14, mass: 400, weight: 4, found: &[Tools, Crew], people: None },
+ Kind { nouns: &["hand-wraps", "sand-gloves", "palm-wraps"], slot: Hands, pair: true, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 12, mass: 200, weight: 2, found: &[Crew], people: QAARI },
+ Kind { nouns: &["vac-gloves", "suit gloves", "pressure gloves"], slot: Hands, pair: true, guards: &[(Pierce, 1)], stanches: false, durability: 16, mass: 500, weight: 2, found: &[Tools, Armoury], people: DRIFT },
+ Kind { nouns: &["gauntlets", "mailed gloves", "vambraces"], slot: Hands, pair: true, guards: &[(Cut, 1), (Blunt, 1)], stanches: false, durability: 26, mass: 1200, weight: 2, found: &[Armoury], people: HOUSES },
+ Kind { nouns: &["psalm-cuffs", "chime-bracers", "prayer-cords"], slot: Hands, pair: true, guards: &[(Hymn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 300, weight: 1, found: &[Rites], people: HOUSES },
+ // Feet.
+ Kind { nouns: &["mag-boots", "deck boots", "grav-boots"], slot: Feet, pair: true, guards: &[(Pierce, 1), (Blunt, 1)], stanches: false, durability: 30, mass: 2400, weight: 4, found: &[Tools, Crew], people: DRIFT },
+ Kind { nouns: &["lineman's boots", "rubber boots", "dielectric boots"], slot: Feet, pair: true, guards: &[(Shock, 1)], stanches: false, durability: 22, mass: 1600, weight: 2, found: &[Tools], people: CANT },
+ Kind { nouns: &["sand-boots", "dune-shoes", "wrapped sandals"], slot: Feet, pair: true, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 800, weight: 2, found: &[Crew], people: QAARI },
+ Kind { nouns: &["greaves", "sabatons", "plated boots"], slot: Feet, pair: true, guards: &[(Cut, 1), (Blunt, 1)], stanches: false, durability: 34, mass: 3000, weight: 1, found: &[Armoury], people: HOUSES },
+ Kind { nouns: &["pilgrim's sandals", "vigil slippers", "chapel boots"], slot: Feet, pair: true, guards: &[(Hymn, 1)], stanches: false, durability: 14, mass: 500, weight: 1, found: &[Rites], people: HOUSES },
+];
+
+/// A piece found where `source` things turn up, not in the best of repair.
+/// `better` shifts the odds toward finer ones: an armoury keeps its stock in
+/// better shape than a berth does.
+pub fn find(rng: &mut Rng, source: Source, better: bool) -> Armor {
+ let kind = KINDS
+ .choose_weighted(rng, |k| k.weight * if k.found.contains(&source) { 5 } else { 1 })
+ .expect("there are kinds of armour");
+ let roll = rng.random_range(0..100) + if better { 25 } else { 0 };
+ let condition = match roll {
+ ..25 => Condition::Worn,
+ 25..65 => Condition::Sound,
+ 65..94 => Condition::Fine,
+ _ => Condition::Relic,
+ };
+ let mut a = forge(rng, kind, condition);
+ let left = match condition {
+ Condition::Worn => rng.random_range(0.3..0.7),
+ Condition::Sound => rng.random_range(0.55..1.0),
+ Condition::Fine => rng.random_range(0.75..1.0),
+ Condition::Relic => 1.0,
+ };
+ a.durability = ((f64::from(a.max_durability) * left).round() as u32).clamp(1, a.max_durability);
+ a
+}
+
+/// A piece for an armourer's rack where `culture` lives, mended whole. Each
+/// people favours its own: Qaari wraps and veils against burns and cuts,
+/// Cant insulated plate against shocks, High House fine plate and warded
+/// pieces against hymns, Drift patched vac-gear against piercing. A `relic`
+/// is rare and dear.
+pub fn stocked(rng: &mut Rng, culture: Culture, relic: bool) -> Armor {
+ let kind = KINDS
+ .choose_weighted(rng, |k| k.weight * if k.people == Some(culture) { 5 } else { 1 })
+ .expect("there are kinds of armour");
+ let fine = if culture == Culture::Houses { 20 } else { 0 };
+ let condition = match rng.random_range(0..100) + fine {
+ _ if relic => Condition::Relic,
+ ..15 => Condition::Worn,
+ 15..70 => Condition::Sound,
+ _ => Condition::Fine,
+ };
+ forge(rng, kind, condition)
+}
+
+/// What a piece is worth to an armourer whole, in scrip: more for guarding
+/// more, keeping wounds closed and lasting longer, and a good deal more for
+/// a fine one or a relic.
+pub fn value(a: &Armor) -> u32 {
+ let guards: i32 = a.guards.values().map(|&g| g.max(0)).sum();
+ let guards = f64::from(guards) + if a.stanches { 1.5 } else { 0.0 };
+ let lasting = 0.6 + f64::from(a.max_durability.min(60)) / 75.0;
+ let condition = match a.condition {
+ Condition::Worn => 0.8,
+ Condition::Sound => 1.0,
+ Condition::Fine => 1.25,
+ Condition::Relic => 3.0,
+ };
+ (12.0 * (guards * lasting).powf(1.25) * condition).round().max(1.0) as u32
+}
+
+/// What it's worth as it is: half its whole value when it's worn out, the
+/// rest coming back as it's mended.
+pub fn worth(a: &Armor) -> f64 {
+ f64::from(value(a)) - mending(a)
+}
+
+/// What mending it whole is worth: half its value, for one worn out.
+pub fn mending(a: &Armor) -> f64 {
+ if a.max_durability == 0 {
+ return 0.0;
+ }
+ let wear = f64::from(a.max_durability.saturating_sub(a.durability));
+ f64::from(value(a)) * 0.5 * wear / f64::from(a.max_durability)
+}
+
+fn forge(rng: &mut Rng, kind: &Kind, condition: Condition) -> Armor {
+ let noun = pick(kind.nouns, rng);
+ let mut a = Armor {
+ title: String::new(),
+ description: String::new(),
+ slot: kind.slot,
+ guards: kind.guards.iter().copied().collect(),
+ stanches: kind.stanches,
+ durability: 0,
+ max_durability: kind.durability,
+ condition,
+ named: false,
+ pair: kind.pair,
+ mass: kind.mass,
+ };
+ // No two quite alike.
+ a.max_durability = (a.max_durability as i32 + rng.random_range(-2..=2)).max(6) as u32;
+ a.mass = (f64::from(a.mass) * rng.random_range(0.9..1.1)).round() as u32;
+
+ let mut words = Vec::new();
+ match condition {
+ Condition::Worn => {
+ a.max_durability = (f64::from(a.max_durability) * 0.7).round() as u32;
+ words.push(pick(WORN, rng));
+ }
+ Condition::Sound => {}
+ Condition::Fine => words.extend(affixes(rng, &mut a, noun, 1)),
+ Condition::Relic => {
+ a.max_durability = (f64::from(a.max_durability) * 1.25).round() as u32;
+ words.extend(affixes(rng, &mut a, noun, 2));
+ }
+ }
+ a.durability = a.max_durability;
+
+ let mut what = words.join(" ");
+ if !what.is_empty() {
+ what.push(' ');
+ }
+ what.push_str(noun);
+ a.description = if a.pair { format!("a pair of {what}") } else { names::a(&what) };
+ if condition == Condition::Relic {
+ a.title = arms::relic_name(rng);
+ a.named = true;
+ } else {
+ a.title = what;
+ }
+ a
+}
+
+/// Up to `n` different affixes that suit the piece, applied; returns their
+/// words. None that the noun already says.
+fn affixes(rng: &mut Rng, a: &mut Armor, noun: &str, n: usize) -> Vec<&'static str> {
+ let mut words = Vec::new();
+ for _ in 0..n {
+ let fits: Vec<&Affix> = AFFIXES
+ .iter()
+ .filter(|f| (f.fits)(a) && !words.contains(&f.word) && !noun.contains(f.word))
+ .collect();
+ let Some(affix) = fits.choose(rng) else { break };
+ (affix.apply)(a);
+ words.push(affix.word);
+ }
+ words
+}
+
+struct Affix {
+ word: &'static str,
+ fits: fn(&Armor) -> bool,
+ apply: fn(&mut Armor),
+}
+
+fn more(a: &mut Armor, dmg: Dmg) {
+ *a.guards.entry(dmg).or_insert(0) += 1;
+}
+
+fn scaled(x: u32, k: f64) -> u32 {
+ (f64::from(x) * k).round() as u32
+}
+
+#[rustfmt::skip]
+const AFFIXES: &[Affix] = &[
+ Affix { word: "padded", fits: |_| true, apply: |a| more(a, Blunt) },
+ Affix { word: "mail-lined", fits: |a| a.slot != Feet, apply: |a| more(a, Cut) },
+ Affix { word: "ceramic-plated", fits: |a| matches!(a.slot, Head | Body), apply: |a| { more(a, Pierce); a.mass = scaled(a.mass, 1.2) } },
+ Affix { word: "insulated", fits: |_| true, apply: |a| more(a, Shock) },
+ Affix { word: "fireproofed", fits: |_| true, apply: |a| more(a, Burn) },
+ Affix { word: "warded", fits: |_| true, apply: |a| more(a, Hymn) },
+ Affix { word: "reinforced", fits: |_| true, apply: |a| { a.max_durability = scaled(a.max_durability, 1.5); a.mass = scaled(a.mass, 1.15) } },
+ Affix { word: "styptic-lined", fits: |a| a.slot == Body && !a.stanches, apply: |a| a.stanches = true },
+ Affix { word: "featherweight", fits: |a| a.mass >= 1000, apply: |a| a.mass = scaled(a.mass, 0.65) },
+];
+
+/// How a worn piece looks.
+const WORN: &[&str] =
+ &["scuffed", "patched", "battered", "taped-up", "grimy", "scorched", "stained", "threadbare"];
+
+/// A few lines describing `n` pieces found in a run of wrecks, for
+/// `orrery armour`.
+pub fn catalogue(rng: &mut Rng, n: usize) -> String {
+ let mut out = String::new();
+ let sources = [Armoury, Crew, Tools, Rites];
+ for i in 0..n {
+ let a = find(rng, sources[i % sources.len()], false);
+ let _ = match a.named {
+ true => writeln!(out, "{} ({}, {})", a.name(), a.description, a.slot.name()),
+ false => writeln!(out, "{} ({})", a.name(), a.slot.name()),
+ };
+ let _ = writeln!(out, " {}", a.guards_text());
+ let _ = writeln!(
+ out,
+ " {} · {:.1} kg · worth {} ¤ whole",
+ a.durability_text(),
+ f64::from(a.mass) / 1000.0,
+ value(&a)
+ );
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::seed;
+
+ #[test]
+ fn found_armour_is_sound() {
+ let mut rng = seed::rng(7);
+ let mut titles = std::collections::HashSet::new();
+ for i in 0..2000 {
+ let source = [Tools, Armoury, Crew, Rites][i % 4];
+ let a = find(&mut rng, source, i % 3 == 0);
+ assert!(a.max_durability >= 4, "{a:?}");
+ assert!((1..=a.max_durability).contains(&a.durability), "{a:?}");
+ assert!(a.guards.values().all(|&g| (1..=5).contains(&g)), "{a:?}");
+ assert!(!a.guards.is_empty() && a.mass > 0);
+ assert!(!a.title.is_empty() && !a.description.is_empty());
+ assert!(!a.description.contains(" "), "{}", a.description);
+ let mut saved = a.clone();
+ saved.mend();
+ assert_eq!(saved, a, "nothing to put right");
+ titles.insert(a.title.clone());
+ }
+ assert!(titles.len() > 150, "only {} different pieces", titles.len());
+ }
+
+ #[test]
+ fn worn_thin_it_guards_less_and_worn_out_nothing() {
+ let mut a = stocked(&mut seed::rng(1), Culture::Drift, false);
+ a.guards = [(Pierce, 2), (Blunt, 1)].into_iter().collect();
+ a.max_durability = 10;
+ a.durability = 10;
+ assert_eq!((a.guard(Pierce), a.guard(Blunt), a.guard(Cut)), (2, 1, 0));
+ let mut news = Vec::new();
+ for _ in 0..10 {
+ news.extend(a.wear_down());
+ }
+ assert_eq!(news.len(), 2, "once when it's thin, once when it's gone: {news:?}");
+ assert!(news[0].contains("wearing thin") && news[1].contains("worn out"));
+ assert_eq!(a.guard(Pierce), 0);
+ a.durability = 4;
+ assert_eq!((a.guard(Pierce), a.guard(Blunt)), (1, 0), "1 less below half");
+ a.repair();
+ assert_eq!(a.guard(Pierce), 2);
+ }
+
+ #[test]
+ fn mending_costs_more_the_more_worn_and_the_finer() {
+ let mut rng = seed::rng(2);
+ let mut a = stocked(&mut rng, Culture::Houses, false);
+ let whole = f64::from(value(&a));
+ assert_eq!(mending(&a), 0.0);
+ a.durability = 0;
+ assert!((mending(&a) - whole / 2.0).abs() < 1e-9, "half its price from ruined");
+ assert!((worth(&a) - whole / 2.0).abs() < 1e-9);
+ a.durability = a.max_durability / 2;
+ let half_worn = mending(&a);
+ assert!(half_worn > 0.0 && half_worn < whole / 2.0);
+ let mut relic = a.clone();
+ relic.condition = Condition::Relic;
+ assert!(mending(&relic) > half_worn, "the finer, the dearer");
+ }
+
+ #[test]
+ fn racks_lean_to_their_peoples_armour() {
+ let mut rng = seed::rng(3);
+ for culture in Culture::ALL {
+ let ours = (0..400)
+ .filter(|_| {
+ let a = stocked(&mut rng, culture, false);
+ KINDS.iter().any(|k| {
+ k.people == Some(culture) && k.nouns.iter().any(|n| a.title.ends_with(n))
+ })
+ })
+ .count();
+ assert!(ours > 160, "{} stock only {ours} of their own in 400", culture.name());
+ let a = stocked(&mut rng, culture, false);
+ assert_eq!(a.durability, a.max_durability, "a rack's pieces are whole");
+ }
+ let relic = stocked(&mut rng, Culture::Cant, true);
+ assert!(relic.named && relic.condition == Condition::Relic);
+ }
+
+ #[test]
+ fn pairs_read_as_pairs() {
+ let mut rng = seed::rng(4);
+ let boots =
+ (0..).map(|_| find(&mut rng, Tools, false)).find(|a| a.pair && !a.named).unwrap();
+ assert!(boots.name().starts_with("a pair of "));
+ assert_eq!(boots.is(), "are");
+ assert!(boots.yours().starts_with("your "));
+ }
+}