//! 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, /// 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 = 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 { 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, } use Dmg::{Blunt, Burn, Cut, Hymn, Pierce, Shock}; use Slot::{Body, Feet, Hands, Head}; use Source::{Armoury, Crew, Rites, Tools}; const QAARI: Option = Some(Culture::Qaari); const CANT: Option = Some(Culture::Cant); const HOUSES: Option = Some(Culture::Houses); const DRIFT: Option = 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 ")); } }