diff options
Diffstat (limited to 'src/dungeon/armour.rs')
| -rw-r--r-- | src/dungeon/armour.rs | 526 |
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 ")); + } +} |