From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/dungeon/armour.rs | 526 +++++++++++++++ src/dungeon/arms.rs | 679 ++++++++++++++++++++ src/dungeon/crawl.rs | 1692 ++++++++++++++++++++++++++++++++++++++++++++++--- src/dungeon/mod.rs | 14 +- src/dungeon/ship.rs | 161 +++-- 5 files changed, 2949 insertions(+), 123 deletions(-) create mode 100644 src/dungeon/armour.rs create mode 100644 src/dungeon/arms.rs (limited to 'src/dungeon') 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, + /// 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 ")); + } +} diff --git a/src/dungeon/arms.rs b/src/dungeon/arms.rs new file mode 100644 index 0000000..d37e1c8 --- /dev/null +++ b/src/dungeon/arms.rs @@ -0,0 +1,679 @@ +//! Weapons: the kinds of damage, the kinds of weapon, the ones you find +//! aboard wrecks (each with its own name and stats), and the geometry of a +//! shot. The rules for using them live in `crawl`. +//! +//! A found weapon is a kind (a cleaver, a scattergun...) rolled up with a +//! condition: worn things hit softer, fine ones carry an affix ("serrated", +//! "long-barrelled"), and the rare relic carries two and a name of its own. + +use std::fmt::Write as _; + +use rand::RngExt; +use rand::seq::IndexedRandom; +use serde::{Deserialize, Serialize}; + +use super::Pos; +use crate::names::{self, Culture, pick}; +use crate::seed::Rng; + +type Rgb = (u8, u8, u8); + +/// The kinds of damage. Each does something besides hurting (see `about`), +/// and every monster takes each kind differently. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Dmg { + Cut, + Blunt, + Pierce, + Burn, + Shock, + Hymn, +} + +impl Dmg { + pub const ALL: [Dmg; 6] = [Dmg::Cut, Dmg::Blunt, Dmg::Pierce, Dmg::Burn, Dmg::Shock, Dmg::Hymn]; + + pub fn name(self) -> &'static str { + match self { + Dmg::Cut => "cut", + Dmg::Blunt => "blunt", + Dmg::Pierce => "pierce", + Dmg::Burn => "burn", + Dmg::Shock => "shock", + Dmg::Hymn => "hymn", + } + } + + pub fn rgb(self) -> Rgb { + match self { + Dmg::Cut => (230, 90, 90), + Dmg::Blunt => (200, 170, 130), + Dmg::Pierce => (200, 205, 215), + Dmg::Burn => (255, 150, 60), + Dmg::Shock => (110, 200, 255), + Dmg::Hymn => (200, 150, 255), + } + } + + /// What it does besides the damage. + pub fn about(self) -> &'static str { + match self { + Dmg::Cut => "Cuts make the living bleed, a little less each turn.", + Dmg::Blunt => "Blows knock things back; into a bulkhead, they stagger.", + Dmg::Pierce => "Goes through armour; fast rounds go on into what's behind.", + Dmg::Burn => "Sets things alight for a few turns.", + Dmg::Shock => "Can stun: the target loses its next turn.", + Dmg::Hymn => "Old songs against what shouldn't be. Can steady you.", + } + } + + /// "slashes", as in "your cleaver slashes the rust-mite". + pub fn verb(self) -> &'static str { + match self { + Dmg::Cut => "slashes", + Dmg::Blunt => "smashes", + Dmg::Pierce => "stabs", + Dmg::Burn => "burns", + Dmg::Shock => "jolts", + Dmg::Hymn => "rings against", + } + } +} + +/// What makes a weapon a gun. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gun { + /// How far a shot flies, in cells. + pub range: i32, + /// How far a shot can stray either side of where you aim, in degrees. + /// Most shots stray less than this; up close it barely matters. + pub spread: f64, + /// Projectiles per shot. Each strays on its own. + pub pellets: u32, + /// Shots before you have to reload. + pub magazine: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Weapon { + /// What it's called, without an article: "serrated cleaver", or for a + /// relic its own name, "Khaseem's Mercy". + pub title: String, + /// What it is: "a fine serrated cleaver". For a relic, the only place + /// that says what kind of thing it is. + pub description: String, + pub dmg: Dmg, + pub damage: (i32, i32), + /// For blows, the chance one lands. Guns ignore it: where a shot lands + /// is down to spread. + pub accuracy: f64, + /// The chance a hit lands twice as hard. + pub crit: f64, + /// How strongly its damage's extra takes hold: bleeding added, turns + /// alight, % chance to knock back, % to stun, things a round passes + /// through, % chance to steady you. See `effect_text`. + pub effect: u32, + pub gun: Option, + /// Rounds left in the magazine. + pub loaded: u32, + /// Part of your own kit, not something you found: never dropped, and + /// never lost. + pub kit: bool, + /// A relic with its own name. + pub named: bool, + pub condition: Condition, + /// What it weighs, in grams. Saves from before weight have none, and + /// `mend` estimates it. + #[serde(default)] + pub mass: u32, +} + +impl Weapon { + /// "a serrated cleaver", "Khaseem's Mercy". + pub fn name(&self) -> String { + if self.named { + self.title.clone() + } else if self.kit { + format!("your {}", self.title) + } else { + names::a(&self.title) + } + } + + /// "your serrated cleaver", "Khaseem's Mercy": the subject of a sentence. + pub fn yours(&self) -> String { + if self.named { self.title.clone() } else { format!("your {}", self.title) } + } + + pub fn is_gun(&self) -> bool { + self.gun.is_some() + } + + /// "bleeds 2", "knockback 35%"; nothing if it does nothing extra. + pub fn effect_text(&self) -> Option { + let n = self.effect; + if n == 0 { + return None; + } + Some(match self.dmg { + Dmg::Cut => format!("bleeds {n}"), + Dmg::Blunt => format!("knockback {n}%"), + Dmg::Pierce => format!("passes through {n}"), + Dmg::Burn => format!("ignites {n}"), + Dmg::Shock => format!("stuns {n}%"), + Dmg::Hymn => format!("steadies {n}%"), + }) + } + + /// "cut 3–6", the damage. + pub fn damage_text(&self) -> String { + let (lo, hi) = self.damage; + let pellets = self.gun.map_or(1, |g| g.pellets); + let times = if pellets > 1 { format!("×{pellets}") } else { String::new() }; + let damage = if lo == hi { lo.to_string() } else { format!("{lo}–{hi}") }; + format!("{} {damage}{times}", self.dmg.name()) + } + + /// Every stat, for the pack screen: "cut 3–6 · hits 80% · crit 5%". + pub fn stats(&self) -> Vec { + let mut first = vec![self.damage_text()]; + match self.gun { + Some(g) => { + first.push(format!("range {}", g.range)); + first.push(format!("spread ±{}°", trim(g.spread))); + } + None => first.push(format!("hits {}%", percent(self.accuracy))), + } + let mut second = Vec::new(); + if let Some(g) = self.gun { + second.push(format!("{}/{} loaded", self.loaded, g.magazine)); + } + second.push(format!("crit {}%", percent(self.crit))); + second.extend(self.effect_text()); + vec![first.join(" · "), second.join(" · ")] + } + + pub fn reload(&mut self) { + if let Some(g) = self.gun { + self.loaded = g.magazine; + } + } + + /// Puts right whatever a save file got wrong: numbers that make sense, + /// and a weight for a weapon saved before things had one. + pub fn mend(&mut self) { + self.damage.0 = self.damage.0.max(1); + self.damage.1 = self.damage.1.max(self.damage.0); + self.accuracy = self.accuracy.clamp(0.0, 1.0); + self.crit = self.crit.clamp(0.0, 1.0); + if let Some(g) = &mut self.gun { + g.range = g.range.max(1); + g.pellets = g.pellets.clamp(1, 12); + g.magazine = g.magazine.max(1); + g.spread = if g.spread.is_finite() { g.spread.clamp(0.0, 90.0) } else { 0.0 }; + self.loaded = self.loaded.min(g.magazine); + } + if self.mass == 0 { + self.mass = self.estimated_mass(); + } + } + + /// What a weapon saved without a weight probably weighs: its kind's, + /// going by what it's called. + fn estimated_mass(&self) -> u32 { + let own = [cutting_torch(), rivet_gun()]; + if self.kit { + return own.iter().find(|w| w.title == self.title).map_or(2000, |w| w.mass); + } + KINDS + .iter() + .find(|k| { + k.nouns.iter().any(|n| self.description.contains(n) || self.title.contains(n)) + }) + .map_or(if self.is_gun() { 3000 } else { 1500 }, |k| k.mass) + } +} + +fn percent(p: f64) -> i32 { + (p * 100.0).round() as i32 +} + +/// 1.5 as "1.5", 6.0 as "6". +fn trim(x: f64) -> String { + let s = format!("{x:.1}"); + s.strip_suffix(".0").map_or(s.clone(), str::to_string) +} + +/// Where a kind of weapon turns up most. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Source { + /// Engine rooms, holds, machine shops. + Tools, + /// Armouries and bridges. + Armoury, + /// Berths and galleys: what the crew kept to hand. + Crew, + /// Chapels and reliquaries. + Rites, +} + +struct Kind { + /// What it might be called; one is picked for each weapon found. + nouns: &'static [&'static str], + dmg: Dmg, + damage: (i32, i32), + accuracy: f64, + effect: u32, + gun: Option, + /// How common it is, relative to the others. + weight: u32, + /// Where it's most likely to turn up. + found: &'static [Source], + /// What one weighs, in grams. + mass: u32, +} + +const fn gun(range: i32, spread: f64, pellets: u32, magazine: u32) -> Option { + Some(Gun { range, spread, pellets, magazine }) +} + +use Source::{Armoury, Crew, Rites, Tools}; + +#[rustfmt::skip] +const KINDS: &[Kind] = &[ + // Blows: walk into something to use them. + Kind { nouns: &["cleaver", "hacking-blade", "machete"], dmg: Dmg::Cut, damage: (3, 6), accuracy: 0.80, effect: 1, gun: None, weight: 4, found: &[Crew, Tools], mass: 1200 }, + Kind { nouns: &["shiv", "boarding knife", "galley knife"], dmg: Dmg::Cut, damage: (1, 4), accuracy: 0.95, effect: 2, gun: None, weight: 4, found: &[Crew], mass: 300 }, + Kind { nouns: &["bone-saw", "chain-blade", "rip-saw"], dmg: Dmg::Cut, damage: (2, 7), accuracy: 0.70, effect: 3, gun: None, weight: 2, found: &[Tools], mass: 1800 }, + Kind { nouns: &["crowbar", "pry-bar", "spanner"], dmg: Dmg::Blunt, damage: (2, 6), accuracy: 0.80, effect: 35, gun: None, weight: 5, found: &[Tools], mass: 1600 }, + Kind { nouns: &["bulkhead maul", "sledge", "breaching hammer"], dmg: Dmg::Blunt, damage: (4, 9), accuracy: 0.65, effect: 70, gun: None, weight: 2, found: &[Tools, Armoury], mass: 5000 }, + Kind { nouns: &["boarding spike", "stiletto", "awl"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 0.85, effect: 0, gun: None, weight: 3, found: &[Armoury, Crew], mass: 500 }, + Kind { nouns: &["thermal lance", "plasma cutter", "heat-knife"], dmg: Dmg::Burn, damage: (3, 7), accuracy: 0.75, effect: 2, gun: None, weight: 2, found: &[Tools, Armoury], mass: 2500 }, + Kind { nouns: &["arc baton", "shock-prod", "stun-maul"], dmg: Dmg::Shock, damage: (2, 4), accuracy: 0.85, effect: 35, gun: None, weight: 3, found: &[Armoury], mass: 1100 }, + Kind { nouns: &["censer-flail", "chime-staff", "bell-hammer"], dmg: Dmg::Hymn, damage: (2, 5), accuracy: 0.80, effect: 30, gun: None, weight: 2, found: &[Rites], mass: 2200 }, + Kind { nouns: &["tuning-blade", "psalter's sword", "cantor's knife"], dmg: Dmg::Hymn, damage: (3, 6), accuracy: 0.75, effect: 20, gun: None, weight: 1, found: &[Rites], mass: 1400 }, + // Guns: `f` to aim and fire. + Kind { nouns: &["slug pistol", "service pistol", "hand-cannon"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 1.0, effect: 0, gun: gun(7, 6.0, 1, 6), weight: 4, found: &[Armoury, Crew], mass: 1100 }, + Kind { nouns: &["rail rifle", "long-gun", "coilgun"], dmg: Dmg::Pierce, damage: (4, 8), accuracy: 1.0, effect: 1, gun: gun(12, 1.5, 1, 2), weight: 1, found: &[Armoury], mass: 4500 }, + Kind { nouns: &["scattergun", "blunderbuss", "grapeshot pistol"], dmg: Dmg::Blunt, damage: (1, 3), accuracy: 1.0, effect: 15, gun: gun(5, 16.0, 5, 2), weight: 3, found: &[Armoury, Crew], mass: 3200 }, + Kind { nouns: &["flamer", "incinerator", "torch-gun"], dmg: Dmg::Burn, damage: (1, 3), accuracy: 1.0, effect: 3, gun: gun(4, 12.0, 3, 3), weight: 2, found: &[Tools, Armoury], mass: 6000 }, + Kind { nouns: &["arc caster", "spark-thrower", "coil projector"], dmg: Dmg::Shock, damage: (2, 5), accuracy: 1.0, effect: 40, gun: gun(5, 3.0, 1, 3), weight: 2, found: &[Armoury, Tools], mass: 3500 }, + Kind { nouns: &["needler", "flechette gun", "dart pistol"], dmg: Dmg::Cut, damage: (1, 2), accuracy: 1.0, effect: 1, gun: gun(7, 8.0, 3, 4), weight: 3, found: &[Crew, Armoury], mass: 1300 }, + Kind { nouns: &["harpoon gun", "bolt-thrower", "spear-gun"], dmg: Dmg::Cut, damage: (3, 7), accuracy: 1.0, effect: 2, gun: gun(6, 4.0, 1, 1), weight: 2, found: &[Tools], mass: 3800 }, + Kind { nouns: &["psalm-horn", "choir-lantern", "hymnal projector"], dmg: Dmg::Hymn, damage: (2, 6), accuracy: 1.0, effect: 25, gun: gun(6, 9.0, 1, 3), weight: 2, found: &[Rites], mass: 2600 }, +]; + +/// Base chance of a critical hit. +const CRIT: f64 = 0.05; + +fn kit(title: &'static str, dmg: Dmg, damage: (i32, i32), gun: Option, mass: u32) -> Weapon { + Weapon { + title: title.to_string(), + description: format!("your own {title}"), + dmg, + damage, + accuracy: 0.8, + crit: CRIT, + effect: 0, + loaded: gun.map_or(0, |g| g.magazine), + gun, + kit: true, + named: false, + condition: Condition::Sound, + mass, + } +} + +/// The torch you cut your way aboard with. It's also what you hit things with +/// until you find better. +pub fn cutting_torch() -> Weapon { + kit("cutting torch", Dmg::Burn, (2, 5), None, 2200) +} + +/// For pinning hull plates; it does for rust-mites, too. +pub fn rivet_gun() -> Weapon { + kit("rivet gun", Dmg::Pierce, (1, 3), gun(6, 5.0, 1, 3), 1800) +} + +/// What a weapon's condition did to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Condition { + Worn, + Sound, + Fine, + Relic, +} + +/// A weapon found where `source` things turn up. `better` shifts the odds +/// toward finer ones: an armoury keeps its stock in better shape than a +/// galley does. +pub fn find(rng: &mut Rng, source: Source, better: bool) -> Weapon { + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if k.found.contains(&source) { 5 } else { 1 }) + .expect("there are weapon kinds"); + 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, + }; + forge(rng, kind, condition) +} + +/// A weapon for an armourer's rack where `culture` lives. Each people +/// favours its own sort: Qaari blades and spear-guns, Cant shock and burn +/// tools, High House duelling pieces in fine condition, Drift guns. A rack +/// keeps nothing too worn to sell; a `relic` is rare and dear. +pub fn stocked(rng: &mut Rng, culture: Culture, relic: bool) -> Weapon { + let favoured = |k: &Kind| match culture { + Culture::Qaari => k.dmg == Dmg::Cut, + Culture::Cant => matches!(k.dmg, Dmg::Shock | Dmg::Burn), + Culture::Houses => matches!(k.dmg, Dmg::Pierce | Dmg::Hymn), + Culture::Drift => k.gun.is_some(), + }; + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if favoured(k) { 4 } else { 1 }) + .expect("there are weapon kinds"); + 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 weapon's worth to an armourer, in scrip: more for more damage, a +/// surer hit, a longer reach and a stronger extra, and a good deal more for +/// a fine one or a relic. +pub fn value(w: &Weapon) -> u32 { + let (lo, hi) = w.damage; + let pellets = w.gun.map_or(1, |g| g.pellets); + let damage = f64::from(lo + hi) / 2.0 * f64::from(pellets); + let aim = match w.gun { + Some(g) => (0.6 + f64::from(g.range.min(12)) / 30.0) * (1.0 - g.spread.min(45.0) / 60.0), + None => w.accuracy, + }; + let n = f64::from(w.effect); + let extra = match w.dmg { + Dmg::Cut | Dmg::Burn => n * 0.8, + Dmg::Pierce => n, + Dmg::Blunt => n / 25.0, + Dmg::Shock | Dmg::Hymn => n / 20.0, + }; + let power = (damage * aim + extra) * (1.0 + 1.5 * w.crit); + let condition = match w.condition { + Condition::Worn => 0.8, + Condition::Sound => 1.0, + Condition::Fine => 1.25, + Condition::Relic => 3.0, + }; + (5.0 * power.powf(1.5) * condition).round().max(1.0) as u32 +} + +fn forge(rng: &mut Rng, kind: &Kind, condition: Condition) -> Weapon { + let noun = pick(kind.nouns, rng); + let mut w = Weapon { + title: String::new(), + description: String::new(), + dmg: kind.dmg, + damage: kind.damage, + accuracy: kind.accuracy, + crit: CRIT, + effect: kind.effect, + gun: kind.gun, + loaded: 0, + kit: false, + named: false, + condition, + mass: kind.mass, + }; + // No two quite alike. + w.damage.1 = (w.damage.1 + rng.random_range(-1..=1)).max(w.damage.0); + if !w.is_gun() { + w.accuracy += rng.random_range(-0.05..0.05); + } + if let Some(g) = &mut w.gun { + g.spread *= rng.random_range(0.85..1.15); + g.spread = (g.spread * 2.0).round() / 2.0; + } + + let mut words = Vec::new(); + match condition { + Condition::Worn => { + scale(&mut w, 0.75); + w.accuracy -= 0.05; + words.push(pick(WORN, rng)); + } + Condition::Sound => {} + Condition::Fine => words.extend(affixes(rng, &mut w, 1)), + Condition::Relic => { + scale(&mut w, 1.25); + w.accuracy += 0.05; + words.extend(affixes(rng, &mut w, 2)); + } + } + w.accuracy = w.accuracy.clamp(0.4, 0.98); + w.crit = w.crit.min(0.5); + w.reload(); + + let mut what = words.join(" "); + if !what.is_empty() { + what.push(' '); + } + what.push_str(noun); + w.description = names::a(&what); + if condition == Condition::Relic { + w.title = relic_name(rng); + w.named = true; + } else { + w.title = what; + } + w +} + +/// Scales the damage, keeping it at least 1–1. +fn scale(w: &mut Weapon, k: f64) { + let s = |x: i32| ((f64::from(x) * k).round() as i32).max(1); + w.damage = (s(w.damage.0), s(w.damage.1).max(s(w.damage.0))); +} + +/// Up to `n` different affixes that suit the weapon, applied; returns their +/// words. +fn affixes(rng: &mut Rng, w: &mut Weapon, n: usize) -> Vec<&'static str> { + let mut words = Vec::new(); + for _ in 0..n { + let fits: Vec<&Affix> = + AFFIXES.iter().filter(|a| (a.fits)(w) && !words.contains(&a.word)).collect(); + let Some(affix) = fits.choose(rng) else { break }; + (affix.apply)(w); + words.push(affix.word); + } + words +} + +struct Affix { + word: &'static str, + fits: fn(&Weapon) -> bool, + apply: fn(&mut Weapon), +} + +fn melee(w: &Weapon) -> bool { + !w.is_gun() +} + +fn gun_mut(w: &mut Weapon) -> &mut Gun { + w.gun.as_mut().expect("affix only fits guns") +} + +#[rustfmt::skip] +const AFFIXES: &[Affix] = &[ + Affix { word: "serrated", fits: |w| w.dmg == Dmg::Cut, apply: |w| w.effect += 1 }, + Affix { word: "keen", fits: |w| melee(w) && matches!(w.dmg, Dmg::Cut | Dmg::Pierce), apply: |w| w.crit += 0.15 }, + Affix { word: "heavy", fits: melee, apply: |w| { w.damage.0 += 1; w.damage.1 += 2; w.accuracy -= 0.1 } }, + Affix { word: "balanced", fits: melee, apply: |w| w.accuracy += 0.1 }, + Affix { word: "weighted", fits: |w| w.dmg == Dmg::Blunt, apply: |w| w.effect += 20 }, + Affix { word: "searing", fits: |w| w.dmg == Dmg::Burn, apply: |w| { w.effect += 1; w.damage.1 += 1 } }, + Affix { word: "overcharged", fits: |w| w.dmg == Dmg::Shock, apply: |w| { w.effect += 20; w.damage.1 += 1 } }, + Affix { word: "hallowed", fits: |w| w.dmg == Dmg::Hymn, apply: |w| { w.effect += 20; w.damage.0 += 1; w.damage.1 += 1 } }, + Affix { word: "long-barrelled", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { let g = gun_mut(w); g.range += 3; g.spread *= 0.6 } }, + Affix { word: "rifled", fits: |w| w.gun.is_some_and(|g| g.spread > 2.0), apply: |w| gun_mut(w).spread *= 0.7 }, + Affix { word: "drum-fed", fits: |w| w.gun.is_some(), apply: |w| { let g = gun_mut(w); g.magazine += g.magazine.div_ceil(2) + 1 } }, + Affix { word: "sawn-off", fits: |w| w.gun.is_some_and(|g| g.pellets > 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 1; let g = gun_mut(w); g.spread *= 1.3; g.range -= 1 } }, + Affix { word: "tungsten-cored", fits: |w| w.dmg == Dmg::Pierce && w.gun.is_some(), apply: |w| w.effect += 1 }, + Affix { word: "high-calibre", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 2; let g = gun_mut(w); g.magazine = g.magazine.saturating_sub(1).max(1) } }, +]; + +/// How a worn weapon looks. +const WORN: &[&str] = &[ + "pitted", "battered", "rust-flecked", "taped-up", "bent", "scorched", "chipped", "patched", + "grimy", "cracked", +]; + +pub(crate) fn relic_name(rng: &mut Rng) -> String { + let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())]; + match rng.random_range(0..5) { + 0 | 1 => format!("{}'s {}", culture.word(rng), pick(VIRTUES, rng)), + 2 | 3 => format!("the {}", pick(TITLES, rng)), + _ => format!("the {} of {}", pick(VIRTUES, rng), culture.place(rng)), + } +} + +const VIRTUES: &[&str] = &[ + "Mercy", "Answer", "Penance", "Tithe", "Benediction", "Reply", "Grief", "Promise", "Wake", + "Lullaby", "Verdict", "Vigil", "Absolution", "Welcome", "Errand", "Patience", "Due", +]; + +const TITLES: &[&str] = &[ + "Quiet Argument", "Last Rites", "Patient Reply", "Red Ledger", "Cold Comfort", "Small Mercy", + "Iron Vow", "Long Silence", "Bright Answer", "Hungry Saint", "Second Opinion", "Final Offer", + "Kind Word", "Open Door", "Ninth Psalm", "Short Sermon", "Closing Hymn", "Widow's Dowry", + "Unpaid Debt", "Late Guest", +]; + +/// How far a shot strays, in degrees: most of the time near the middle, +/// never past `spread` either side. +pub fn stray(rng: &mut Rng, spread: f64) -> f64 { + spread * (rng.random_range(0.0..1.0) - rng.random_range(0.0..1.0)) +} + +/// The stray at fraction `u` of the way through all strays, smallest first: +/// `stray` is equally likely to land in any of `n` evenly spaced slices of +/// these. For working out the odds of a shot without rolling dice. +pub fn stray_quantile(u: f64, spread: f64) -> f64 { + if u < 0.5 { + spread * (-1.0 + (2.0 * u).sqrt()) + } else { + spread * (1.0 - (2.0 * (1.0 - u)).sqrt()) + } +} + +/// The cells a shot from `from` crosses, aimed at the middle of `aim` but +/// straying `deviation` degrees, out to `range` cells. Doesn't include +/// `from`, and knows nothing of walls; the caller stops it where it hits. +pub fn flight(from: Pos, aim: Pos, deviation: f64, range: i32) -> Vec { + let (dx, dy) = (f64::from(aim.x - from.x), f64::from(aim.y - from.y)); + if dx == 0.0 && dy == 0.0 { + return Vec::new(); + } + let angle = dy.atan2(dx) + deviation.to_radians(); + let (cos, sin) = (angle.cos(), angle.sin()); + let (x0, y0) = (f64::from(from.x) + 0.5, f64::from(from.y) + 0.5); + // Into every cell whose edge is within `range` and a half of the middle + // of `from`, a twentieth of a cell at a time. + let steps = (f64::from(range) + 0.5) * 20.0; + let mut cells: Vec = Vec::new(); + for i in 1..steps as i32 { + let t = f64::from(i) / 20.0; + let p = Pos::new((x0 + cos * t).floor() as i32, (y0 + sin * t).floor() as i32); + if p != from && cells.last() != Some(&p) { + cells.push(p); + } + } + cells +} + +/// A few lines describing `n` weapons found in a run of wrecks, for +/// `orrery weapons`. +pub fn catalogue(rng: &mut Rng, n: usize) -> String { + let mut out = String::new(); + let sources = [Tools, Armoury, Crew, Rites]; + for i in 0..n { + let w = find(rng, sources[i % sources.len()], false); + let _ = match w.named { + true => writeln!(out, "{} ({})", w.name(), w.description), + false => writeln!(out, "{}", w.name()), + }; + for line in w.stats() { + let _ = writeln!(out, " {line}"); + } + let _ = writeln!(out, " {:.1} kg", f64::from(w.mass) / 1000.0); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed; + + #[test] + fn found_weapons_are_sound() { + let mut rng = seed::rng(4); + let mut titles = std::collections::HashSet::new(); + for i in 0..2000 { + let source = [Tools, Armoury, Crew, Rites][i % 4]; + let w = find(&mut rng, source, i % 3 == 0); + assert!(w.damage.0 >= 1 && w.damage.0 <= w.damage.1, "{w:?}"); + assert!((0.4..=0.98).contains(&w.accuracy), "{w:?}"); + assert!(!w.title.is_empty() && !w.description.is_empty()); + if let Some(g) = w.gun { + assert!(g.range >= 3 && g.magazine >= 1 && g.pellets >= 1 && g.spread > 0.0); + assert_eq!(w.loaded, g.magazine, "found loaded"); + } + assert!(w.mass > 0); + // A save from before weight gets the same weight back. + let mut saved = w.clone(); + saved.mass = 0; + saved.mend(); + assert_eq!(saved.mass, w.mass, "{}", w.description); + titles.insert(w.title.clone()); + } + assert!(titles.len() > 300, "only {} different weapons", titles.len()); + } + + #[test] + fn every_kind_of_damage_turns_up() { + let mut rng = seed::rng(5); + let found: Vec = + (0..400).map(|_| find(&mut rng, Source::Armoury, false).dmg).collect(); + for dmg in Dmg::ALL { + assert!(found.contains(&dmg), "never found a {} weapon", dmg.name()); + } + } + + #[test] + fn shots_fly_straight_when_they_dont_stray() { + let from = Pos::new(2, 2); + let path = flight(from, Pos::new(6, 2), 0.0, 4); + assert_eq!(path, (3..=6).map(|x| Pos::new(x, 2)).collect::>()); + let diagonal = flight(from, Pos::new(5, 5), 0.0, 4); + assert_eq!(diagonal[..3], [Pos::new(3, 3), Pos::new(4, 4), Pos::new(5, 5)]); + assert!(flight(from, from, 0.0, 5).is_empty()); + } + + #[test] + fn strays_stay_within_the_spread() { + let mut rng = seed::rng(6); + for _ in 0..1000 { + assert!(stray(&mut rng, 8.0).abs() <= 8.0); + } + assert_eq!(stray_quantile(0.5, 8.0), 0.0); + assert!(stray_quantile(0.0, 8.0) <= -7.99 && stray_quantile(1.0, 8.0) >= 7.99); + assert!(stray_quantile(0.25, 8.0) < stray_quantile(0.3, 8.0)); + } +} diff --git a/src/dungeon/crawl.rs b/src/dungeon/crawl.rs index c5196b9..72d8a35 100644 --- a/src/dungeon/crawl.rs +++ b/src/dungeon/crawl.rs @@ -1,31 +1,110 @@ //! The rules of a crawl: moving, fighting, picking things up, and what the //! derelict's inhabitants do each turn. Nothing here draws; the scene turns //! keys into `Action`s and draws whatever state results. +//! +//! You fight with what you've readied: walk into something to hit it with +//! your blade or club, or fire your gun at anything in range. Every monster +//! takes each kind of damage its own way (weak to some, shrugging off +//! others), and a monster's armour comes off every hit but a piercing one. +//! Yours guards against blows by kind, and wears as it does (see +//! `armour`). Cuts bleed, fires burn, blows knock back and shocks stun; see +//! `arms::Dmg`. + +use std::collections::BTreeSet; use rand::RngExt; +use serde::{Deserialize, Serialize}; +use super::arms::{self, Dmg, Gun, Weapon}; use super::fov::{self, Fov}; use super::ship; use super::{Map, Pos, Tile}; -use crate::names::Artifact; +use crate::items::{self, Gear, Item, Slot, Spot, Stack}; use crate::seed::{self, Rng}; /// How far your lamp reaches, in cells. pub const SIGHT_RADIUS: i32 = 8; const PLAYER_HP: i32 = 20; -/// Your cutting torch. -const PLAYER_DAMAGE: (i32, i32) = (2, 5); -const PLAYER_HIT: f64 = 0.8; -const MONSTER_HIT: f64 = 0.7; -const MEDPATCH_HEAL: i32 = 8; +pub const MEDPATCH_HEAL: i32 = 8; const LOG_LENGTH: usize = 50; +/// Bleeding doesn't get worse than this. +const MAX_BLEEDING: u32 = 6; +/// How many evenly spread strays `odds` tries. +const ODDS_SAMPLES: u32 = 64; +/// Gunfire wakes anything within this many steps of you. +const GUNFIRE_CARRIES: u32 = 7; + +/// How something takes a kind of damage. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Affinity { + /// Half as much again. + Weak, + Normal, + /// Half. + Resists, + /// None at all. + Immune, +} + +impl Affinity { + pub fn scale(self, damage: i32) -> i32 { + match self { + Affinity::Weak => (damage * 3 + 1) / 2, + Affinity::Normal => damage, + Affinity::Resists => (damage + 1) / 2, + Affinity::Immune => 0, + } + } + + /// As in "the rust-mite is weak to blunt". + pub fn phrase(self) -> &'static str { + match self { + Affinity::Weak => "is weak to", + Affinity::Normal => "takes no more or less from", + Affinity::Resists => "resists", + Affinity::Immune => "is immune to", + } + } +} + +pub struct Attack { + pub dmg: Dmg, + pub damage: (i32, i32), + /// The chance a blow lands. Shots ignore it; they stray instead. + pub hit: f64, + /// Bleeding it leaves you with. + pub bleed: u32, + /// Turns it sets you alight for. + pub ignite: u32, + /// The chance it knocks you back a cell. + pub knock: f64, + /// Shoots from where it is instead of closing in. + pub gun: Option, +} + +const fn bite(dmg: Dmg, damage: (i32, i32), hit: f64, bleed: u32) -> Attack { + Attack { dmg, damage, hit, bleed, ignite: 0, knock: 0.0, gun: None } +} pub struct MonsterKind { pub name: &'static str, pub glyph: char, pub rgb: (u8, u8, u8), pub hp: i32, - pub damage: (i32, i32), + /// Taken off every hit except a piercing one, though a hit always does + /// at least 1. + pub armour: i32, + /// Whether cuts make it bleed. + pub blood: bool, + pub weak: &'static [Dmg], + pub resists: &'static [Dmg], + pub immune: &'static [Dmg], + pub attack: Attack, + /// Steps it takes a turn: none for things bolted to the deck. + pub speed: u32, + /// Lumbers: steps only every other turn, though it hits whenever you're + /// beside it. + pub slow: bool, /// As in "the rust-mite bites you". pub verb: &'static str, /// How common it is, relative to the others. @@ -34,12 +113,73 @@ pub struct MonsterKind { pub unique: bool, } +impl MonsterKind { + pub fn affinity(&self, dmg: Dmg) -> Affinity { + if self.immune.contains(&dmg) { + Affinity::Immune + } else if self.resists.contains(&dmg) { + Affinity::Resists + } else if self.weak.contains(&dmg) { + Affinity::Weak + } else { + Affinity::Normal + } + } +} + #[rustfmt::skip] pub const MONSTERS: &[MonsterKind] = &[ - MonsterKind { name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, damage: (1, 2), verb: "bites", weight: 5, unique: false }, - MonsterKind { name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, damage: (1, 3), verb: "zaps", weight: 3, unique: false }, - MonsterKind { name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, damage: (2, 4), verb: "claws at", weight: 2, unique: false }, - MonsterKind { name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, damage: (3, 6), verb: "sings into", weight: 1, unique: true }, + MonsterKind { + name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, armour: 1, blood: false, + weak: &[Dmg::Blunt, Dmg::Burn], resists: &[Dmg::Cut], immune: &[], + attack: bite(Dmg::Pierce, (1, 2), 0.7, 0), speed: 1, slow: false, verb: "bites", weight: 5, unique: false, + }, + MonsterKind { + name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, armour: 1, blood: false, + weak: &[Dmg::Shock, Dmg::Blunt], resists: &[Dmg::Cut], immune: &[], + attack: bite(Dmg::Shock, (1, 3), 0.7, 0), speed: 1, slow: false, verb: "zaps", weight: 3, unique: false, + }, + MonsterKind { + name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, armour: 0, blood: true, + weak: &[Dmg::Cut], resists: &[Dmg::Pierce], immune: &[], + attack: bite(Dmg::Cut, (2, 4), 0.7, 1), speed: 1, slow: false, verb: "claws at", weight: 2, unique: false, + }, + MonsterKind { + name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, armour: 0, blood: false, + weak: &[Dmg::Hymn, Dmg::Shock], resists: &[Dmg::Cut, Dmg::Blunt], immune: &[Dmg::Pierce], + attack: bite(Dmg::Hymn, (3, 6), 0.7, 0), speed: 1, slow: false, verb: "sings into", weight: 1, unique: true, + }, + MonsterKind { + name: "bilge-hound", glyph: 'H', rgb: (214, 170, 96), hp: 6, armour: 0, blood: true, + weak: &[Dmg::Burn], resists: &[], immune: &[], + attack: bite(Dmg::Cut, (1, 3), 0.75, 1), speed: 2, slow: false, verb: "savages", weight: 2, unique: false, + }, + MonsterKind { + name: "bulkhead sentry", glyph: 'S', rgb: (226, 96, 72), hp: 8, armour: 2, blood: false, + weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[], + attack: Attack { + dmg: Dmg::Pierce, damage: (1, 2), hit: 1.0, bleed: 0, ignite: 0, knock: 0.0, + gun: Some(Gun { range: 7, spread: 6.0, pellets: 1, magazine: 3 }), + }, + speed: 0, slow: false, verb: "shoots", weight: 1, unique: false, + }, + MonsterKind { + name: "welder drone", glyph: 'w', rgb: (255, 150, 60), hp: 7, armour: 1, blood: false, + weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[Dmg::Burn], + attack: Attack { + dmg: Dmg::Burn, damage: (1, 2), hit: 1.0, bleed: 0, ignite: 2, knock: 0.0, + gun: Some(Gun { range: 4, spread: 10.0, pellets: 1, magazine: 3 }), + }, + speed: 1, slow: false, verb: "torches", weight: 2, unique: false, + }, + MonsterKind { + name: "cargo loader", glyph: 'L', rgb: (222, 184, 70), hp: 12, armour: 2, blood: false, + weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[], + attack: Attack { + dmg: Dmg::Blunt, damage: (2, 5), hit: 0.65, bleed: 0, ignite: 0, knock: 0.5, gun: None, + }, + speed: 1, slow: true, verb: "slams", weight: 2, unique: false, + }, ]; pub struct Monster { @@ -48,17 +188,44 @@ pub struct Monster { pub hp: i32, /// Dormant until it comes into view; then it hunts you. pub awake: bool, + /// What it'll bleed next turn, one less each turn after. + pub bleeding: u32, + /// Turns it has left alight. + pub burning: u32, + /// Turns it'll lose. + pub stunned: u32, + /// Rounds left before it has to reload, if it has a gun. + pub loaded: u32, } -pub enum Item { - Artifact(Artifact), - Salvage { name: &'static str, value: u32 }, - Medpatch, +impl Monster { + pub fn new(kind: &'static MonsterKind, pos: Pos) -> Monster { + let loaded = kind.attack.gun.map_or(0, |g| g.magazine); + Monster { + kind, + pos, + hp: kind.hp, + awake: false, + bleeding: 0, + burning: 0, + stunned: 0, + loaded, + } + } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Loot { pub pos: Pos, - pub item: Item, + pub stack: Stack, + /// Put down on purpose: walking over it doesn't pick it up again. + pub left: bool, +} + +impl Loot { + pub fn new(pos: Pos, item: Item) -> Loot { + Loot { pos, stack: Stack::one(item), left: false } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -75,9 +242,52 @@ pub enum Action { Move(i32, i32), Wait, UseMedpatch, + /// Fire your gun at a cell. + Fire(Pos), + Reload, + /// Ready the weapon at this index in your pack: a gun to fire, or + /// anything else to hit things with. + 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), + /// Put this many of what's at a spot on you down where you stand. + /// Takes no time. + Drop(Spot, u32), + /// Pick up this many of the `n`th thing where you stand (see `here`). + Take(usize, u32), + /// Pick up everything here there's room for, even what you put down. + PickUp, +} + +/// One round's flight, for drawing. +pub struct Trace { + /// Every cell it crossed, in order, ending where it stopped. + pub cells: Vec, + pub dmg: Dmg, + /// Whether it ended in something it hit, rather than a wall or the air. + pub hit: bool, + /// How far behind the turn's first shot it sets off, in cells of + /// flight: shots fired in answer wait for the ones before to land. + pub delay: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Target { + You, + Monster(usize), +} + +struct Shot { + cells: Vec, + struck: Vec, } pub struct Crawl { + /// What the ship and its dice are made from. + pub seed: u64, /// "the Vexant". pub name: String, /// "Cant hauler". @@ -88,27 +298,131 @@ pub struct Crawl { pub max_hp: i32, pub monsters: Vec, pub loot: Vec, - /// What you're carrying. It's only yours once you're back through the airlock. - pub artifacts: Vec, - pub scrip: u32, - pub medpatches: u32, + /// What you're carrying. What you found is only yours once you're back + /// through the airlock. + pub gear: Gear, + /// What you'll bleed next turn, one less each turn after. + pub bleeding: u32, + /// Whatever opened the wound, in case it's the death of you. + bled_by: &'static str, + /// Turns you'll go on burning, 1–2 a turn. + pub burning: u32, + /// Whatever set you alight. + burned_by: &'static str, + /// Knocked into a bulkhead: you lose your next turn. + reeling: bool, + /// How each kind of monster takes each kind of damage, as far as you've + /// found out by hitting them. + pub known: BTreeSet<(&'static str, Dmg)>, + /// The kinds of what's died since whoever's keeping count last took + /// them. + pub slain: Vec<&'static str>, + /// This turn's shots, yours and theirs, in the order they were fired. + pub volley: Vec, pub fov: Fov, /// Cells you've seen at some point, drawn from memory when out of sight. seen: Vec, /// Messages, oldest first. pub log: Vec, + /// How many messages there have been, however many the log still holds. + pub said: u32, pub turns: u32, pub outcome: Option, + /// Whether you've been told how to ready what you pick up. + shown_pack: bool, /// Dice for fights. Separate from the ship's seed, so the same ship and /// the same moves always play out the same way. rng: Rng, } +/// A crawl as it stands, for a save: everything that's changed since the +/// ship was made from its seed. `Crawl::resume` makes the ship again and +/// puts it all back. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedCrawl { + pub seed: u64, + /// The wreck's name, to show without making the ship. + pub name: String, + player: Pos, + hp: i32, + max_hp: i32, + monsters: Vec, + loot: Vec, + gear: Gear, + #[serde(default)] + bleeding: u32, + /// The kind of monster that opened your wound. + #[serde(default)] + bled_by: String, + #[serde(default)] + burning: u32, + /// The kind of monster that set you alight. + #[serde(default)] + burned_by: String, + /// The doors standing open. + #[serde(default)] + opened: Vec, + /// Every cell you've seen, in hex, four cells to a digit. + #[serde(default)] + seen: String, + #[serde(default)] + log: Vec, + #[serde(default)] + turns: u32, + #[serde(default)] + shown_pack: bool, +} + +/// A monster, with its kind by name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct SavedMonster { + kind: String, + pos: Pos, + hp: i32, + awake: bool, + bleeding: u32, + burning: u32, + stunned: u32, + loaded: u32, +} + +/// Cells as hex, four to a digit, the first in the lowest bit. +fn to_hex(cells: &[bool]) -> String { + cells + .chunks(4) + .map(|four| { + let n = four.iter().enumerate().fold(0, |n, (i, &on)| n | (u32::from(on) << i)); + char::from_digit(n, 16).unwrap_or('0') + }) + .collect() +} + +/// `len` cells back from hex; any short of that are unseen. +fn from_hex(text: &str, len: usize) -> Vec { + let mut cells: Vec = text + .chars() + .flat_map(|c| { + let n = c.to_digit(16).unwrap_or(0); + (0..4).map(move |i| n >> i & 1 == 1) + }) + .collect(); + cells.resize(len, false); + cells +} + +/// The name and description ("Cant hauler") of the derelict that +/// `Crawl::new(seed)` would board, without starting a crawl. +pub fn describe(seed: u64) -> (String, String) { + let ship = ship::generate(seed::derive(seed, "ship", 0)); + (ship.name, ship.description) +} + impl Crawl { pub fn new(seed: u64) -> Crawl { let ship = ship::generate(seed::derive(seed, "ship", 0)); let cells = (ship.map.width * ship.map.height) as usize; let mut crawl = Crawl { + seed, fov: fov::compute(&ship.map, ship.start, SIGHT_RADIUS), name: ship.name, description: ship.description, @@ -118,13 +432,21 @@ impl Crawl { max_hp: PLAYER_HP, monsters: ship.monsters, loot: ship.loot, - artifacts: Vec::new(), - scrip: 0, - medpatches: 0, + gear: Gear::default(), + bleeding: 0, + bled_by: "", + burning: 0, + burned_by: "", + reeling: false, + known: BTreeSet::new(), + slain: Vec::new(), + volley: Vec::new(), seen: vec![false; cells], log: Vec::new(), + said: 0, turns: 0, outcome: None, + shown_pack: false, rng: seed::rng(seed::derive(seed, "dice", 0)), }; crawl.say(format!("You board {}, a derelict {}.", crawl.name, crawl.description)); @@ -132,44 +454,193 @@ impl Crawl { crawl } + /// Everything about this crawl a save needs to bring it back. + pub fn saved(&self) -> SavedCrawl { + let open = |p: &Pos| self.map.tile(*p) == Tile::Door { open: true }; + SavedCrawl { + seed: self.seed, + name: self.name.clone(), + player: self.player, + hp: self.hp, + max_hp: self.max_hp, + monsters: self + .monsters + .iter() + .map(|m| SavedMonster { + kind: m.kind.name.to_string(), + pos: m.pos, + hp: m.hp, + awake: m.awake, + bleeding: m.bleeding, + burning: m.burning, + stunned: m.stunned, + loaded: m.loaded, + }) + .collect(), + loot: self.loot.clone(), + gear: self.gear.clone(), + bleeding: self.bleeding, + bled_by: self.bled_by.to_string(), + burning: self.burning, + burned_by: self.burned_by.to_string(), + opened: self.map.positions().filter(open).collect(), + seen: to_hex(&self.seen), + log: self.log.clone(), + turns: self.turns, + shown_pack: self.shown_pack, + } + } + + /// The crawl a save kept, made again: the same ship, with everything + /// that had changed aboard it put back. `None` if it no longer fits the + /// ship its seed makes now. The dice roll afresh from here, and what + /// you know of monsters is the scene's to fill in. + pub fn resume(saved: &SavedCrawl) -> Option { + let mut crawl = Crawl::new(saved.seed); + crawl.log.clear(); + for &p in &saved.opened { + if crawl.map.contains(p) && matches!(crawl.map.tile(p), Tile::Door { .. }) { + crawl.map.set(p, Tile::Door { open: true }); + } + } + let fits = |p: Pos| crawl.map.contains(p) && crawl.map.tile(p).walkable(); + if !fits(saved.player) { + return None; + } + let kind = |name: &str| MONSTERS.iter().find(|k| k.name == name); + let monsters = saved + .monsters + .iter() + .filter(|m| fits(m.pos) && m.hp > 0) + .filter_map(|m| { + Some(Monster { + kind: kind(&m.kind)?, + pos: m.pos, + hp: m.hp, + awake: m.awake, + bleeding: m.bleeding, + burning: m.burning, + stunned: m.stunned, + loaded: m.loaded, + }) + }) + .collect(); + let loot = + saved.loot.iter().filter(|l| fits(l.pos) && l.stack.count > 0).cloned().collect(); + crawl.monsters = monsters; + crawl.loot = loot; + crawl.player = saved.player; + crawl.max_hp = saved.max_hp.max(1); + crawl.hp = saved.hp.clamp(1, crawl.max_hp); + crawl.gear = saved.gear.clone(); + crawl.gear.mend(); + crawl.bled_by = kind(&saved.bled_by).map_or("", |k| k.name); + crawl.bleeding = if crawl.bled_by.is_empty() { 0 } else { saved.bleeding }; + crawl.burned_by = kind(&saved.burned_by).map_or("", |k| k.name); + crawl.burning = if crawl.burned_by.is_empty() { 0 } else { saved.burning }; + crawl.seen = from_hex(&saved.seen, crawl.seen.len()); + crawl.log = saved.log.iter().rev().take(LOG_LENGTH).rev().cloned().collect(); + crawl.turns = saved.turns; + crawl.shown_pack = saved.shown_pack; + crawl.rng = seed::rng(seed::derive(saved.seed, "dice", u64::from(saved.turns) + 1)); + crawl.fov = fov::compute(&crawl.map, crawl.player, SIGHT_RADIUS); + for p in crawl.map.positions() { + if crawl.fov.contains(p) { + let i = crawl.map.index(p); + crawl.seen[i] = true; + } + } + Some(crawl) + } + pub fn seen(&self, p: Pos) -> bool { self.map.contains(p) && self.seen[self.map.index(p)] } pub fn monster_at(&self, p: Pos) -> Option<&Monster> { - self.monsters.iter().find(|m| m.pos == p) + self.monsters.iter().find(|m| m.pos == p && m.hp > 0) + } + + fn alive_at(&self, p: Pos) -> Option { + self.monsters.iter().position(|m| m.pos == p && m.hp > 0) } pub fn loot_at(&self, p: Pos) -> Option<&Loot> { self.loot.iter().find(|l| l.pos == p) } + /// What's lying where you stand, as indices into `loot`. + pub fn here(&self) -> Vec { + (0..self.loot.len()).filter(|&i| self.loot[i].pos == self.player).collect() + } + /// The name of where you're standing. pub fn location(&self) -> &'static str { self.map.room_at(self.player).map_or("a dark passage", |r| r.name) } + /// What you know of how `kind` takes `dmg`, if you've hit one with it. + pub fn knows(&self, kind: &MonsterKind, dmg: Dmg) -> Option { + self.known.contains(&(kind.name, dmg)).then(|| kind.affinity(dmg)) + } + /// Takes your turn, then everything else's. Does nothing once the crawl /// is over, or if the action wouldn't take any time (walking into a wall). pub fn act(&mut self, action: Action) { if self.outcome.is_some() { return; } + self.volley.clear(); let took_time = match action { Action::Wait => true, Action::Move(dx, dy) => self.step(dx, dy), Action::UseMedpatch => self.use_medpatch(), + Action::Fire(aim) => self.fire(aim), + Action::Reload => self.reload(), + Action::Ready(i) => self.outfit(|g| g.ready(i)), + Action::Wear(i) => self.outfit(|g| g.wear(i)), + Action::TakeOff(slot) => self.outfit(|g| g.take_off(slot)), + Action::Eat(i) => self.outfit(|g| g.eat(i)), + Action::Drop(spot, count) => { + self.put_down(spot, count); + false + } + Action::Take(n, count) => match self.here().get(n) { + Some(&i) => self.take(i, count), + None => false, + }, + Action::PickUp => self.pick_up(true), }; + self.bury(); if !took_time || self.outcome.is_some() { return; } self.turns += 1; + self.fester(); + self.bury(); + // Where you are now is what things can see; nothing new wakes until + // they've all had their turn. + self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS); self.monsters_act(); + self.bury(); + // Knocked into a bulkhead, you lose a turn while they have theirs. + if std::mem::take(&mut self.reeling) && self.outcome.is_none() { + self.say("You reel, and lose a turn.".into()); + self.turns += 1; + self.fester(); + self.bury(); + self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS); + self.monsters_act(); + self.bury(); + // Not twice running. + self.reeling = false; + } self.look(); } - fn say(&mut self, message: String) { + pub fn say(&mut self, message: String) { self.log.push(message); + self.said += 1; if self.log.len() > LOG_LENGTH { self.log.remove(0); } @@ -197,10 +668,17 @@ impl Crawl { } } + /// Clears away the dead. Everything that kills says so as it does. + fn bury(&mut self) { + let dead = self.monsters.iter().filter(|m| m.hp <= 0).map(|m| m.kind.name); + self.slain.extend(dead); + self.monsters.retain(|m| m.hp > 0); + } + fn step(&mut self, dx: i32, dy: i32) -> bool { let target = self.player.offset(dx, dy); - if let Some(i) = self.monsters.iter().position(|m| m.pos == target) { - self.attack(i); + if let Some(i) = self.alive_at(target) { + self.strike(i); return true; } match self.map.tile(target) { @@ -216,113 +694,663 @@ impl Crawl { } tile if tile.walkable() => { self.player = target; - self.pick_up(); + self.pick_up(false); true } _ => false, } } - fn pick_up(&mut self) { - while let Some(i) = self.loot.iter().position(|l| l.pos == self.player) { - match self.loot.remove(i).item { - Item::Artifact(artifact) => { - self.say(format!("You find {}.", artifact.unidentified)); - self.artifacts.push(artifact); - } - Item::Salvage { name, value } => { - self.scrip += value; - self.say(format!("You salvage {name} ({value} ¤).")); - } - Item::Medpatch => { - self.medpatches += 1; - self.say("You pocket a medpatch.".into()); - } + /// Picks up what's here that there's room for: everything, if you + /// asked, or else whatever you didn't put down yourself. Says whether + /// you took anything. + fn pick_up(&mut self, asked: bool) -> bool { + let here = self.here(); + if asked && here.is_empty() { + self.say("There's nothing here to pick up.".into()); + } + let mut took = false; + // From the last, so taking one doesn't move the rest. + for &i in here.iter().rev() { + let loot = &self.loot[i]; + if loot.left && !asked { + let name = loot.stack.name(); + self.say(format!("You left {name} here (g picks it up).")); + } else if asked || self.gear.fits(&loot.stack.item) >= loot.stack.count { + took |= self.take(i, loot.stack.count); + } else { + let (name, kg) = (loot.stack.name(), items::kg(loot.stack.mass())); + self.say(format!("There's {name} here ({kg} kg): too much to carry (g, i).")); + } + } + took + } + + /// Picks up as many as there's room for, up to `count`, of the loot at + /// `i`. Says whether you took any. + fn take(&mut self, i: usize, count: u32) -> bool { + let loot = &self.loot[i]; + let count = count.min(loot.stack.count).min(self.gear.fits(&loot.stack.item)); + if count == 0 { + let name = loot.stack.name(); + let room = items::kg(self.gear.room()); + self.say(format!("You've no room for {name}: only {room} kg to spare.")); + return false; + } + let more = count < loot.stack.count; + let stack = if more { + self.loot[i].stack.count -= count; + Stack { item: self.loot[i].stack.item.clone(), count } + } else { + self.loot.remove(i).stack + }; + let name = stack.name(); + self.say(match &stack.item { + Item::Artifact(a) => format!("You find {}.", a.unidentified), + Item::Salvage(s) => { + format!("You salvage {name} ({} kg, worth about {} ¤).", items::kg(s.mass), s.value) } + Item::Medpatch => format!("You pocket {name}."), + Item::Weapon(w) => format!("You take {name} ({}).", w.damage_text()), + Item::Armor(a) => format!("You take {name} ({}).", a.guards_text()), + Item::Food(_) => format!("You take {name}."), + }); + if more { + self.say("You've no room for the rest.".into()); + } + let how = match stack.item { + Item::Weapon(_) => Some("ready it"), + Item::Armor(_) => Some("put it on"), + _ => None, + }; + self.gear.add(stack); + if let Some(how) = how + && !self.shown_pack + { + self.shown_pack = true; + self.say(format!("Your pack (i) is where you {how}.")); } + true } fn use_medpatch(&mut self) -> bool { - if self.medpatches == 0 { + if self.gear.medpatches() == 0 { self.say("You have no medpatches.".into()); return false; } - if self.hp == self.max_hp { + if self.hp == self.max_hp && self.bleeding == 0 && self.burning == 0 { self.say("You're not hurt.".into()); return false; } - self.medpatches -= 1; + self.gear.take_medpatch(); let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp); self.hp += healed; - self.say(format!("The medpatch stings, then numbs (+{healed}).")); + let stanched = if self.bleeding > 0 { " The bleeding stops." } else { "" }; + let smothered = if self.burning > 0 { " Its foam smothers the flames." } else { "" }; + self.bleeding = 0; + self.burning = 0; + self.say(format!("The medpatch stings, then numbs (+{healed}).{stanched}{smothered}")); + true + } + + /// Changes what you have ready or wear, or eats something: a turn's + /// work if it works, and nothing if it doesn't. + fn outfit(&mut self, change: impl FnOnce(&mut Gear) -> Result) -> bool { + match change(&mut self.gear) { + Ok(said) => { + self.say(said); + true + } + Err(said) => { + self.say(said); + false + } + } + } + + fn put_down(&mut self, spot: Spot, count: u32) { + match self.gear.remove(spot, count) { + Ok(stack) => { + self.say(format!("You put down {}.", stack.name())); + self.loot.push(Loot { pos: self.player, stack, left: true }); + } + Err(said) => self.say(said), + } + } + + fn reload(&mut self) -> bool { + let w = &mut self.gear.gun; + let Some(gun) = w.gun else { return false }; + let yours = w.yours(); + if w.loaded == gun.magazine { + self.say(format!("{} is loaded.", capitalise(&yours))); + return false; + } + w.reload(); + self.say(format!("You reload {yours}.")); true } - fn attack(&mut self, i: usize) { - let name = self.monsters[i].kind.name; + /// Hits monster `i` with your readied blade or club. + fn strike(&mut self, i: usize) { + let weapon = self.gear.melee.clone(); self.monsters[i].awake = true; - if !self.rng.random_bool(PLAYER_HIT) { - self.say(format!("Your torch misses the {name}.")); + if !self.rng.random_bool(weapon.accuracy) { + let name = self.monsters[i].kind.name; + self.say(format!("{} misses the {name}.", capitalise(&weapon.yours()))); return; } - let damage = self.rng.random_range(PLAYER_DAMAGE.0..=PLAYER_DAMAGE.1); - self.monsters[i].hp -= damage; - if self.monsters[i].hp <= 0 { - self.monsters.remove(i); - self.say(format!("You burn the {name} down to slag.")); + self.wound(i, &weapon, 1, self.player); + } + + /// Fires your gun at `aim`. With the magazine empty, reloads instead. + fn fire(&mut self, aim: Pos) -> bool { + let weapon = self.gear.gun.clone(); + let Some(gun) = weapon.gun else { return false }; + if aim == self.player || !self.map.contains(aim) { + return false; + } + if weapon.loaded == 0 { + return self.reload(); + } + self.gear.gun.loaded -= 1; + let passes = if weapon.dmg == Dmg::Pierce { weapon.effect } else { 0 }; + let mut hits = vec![0; self.monsters.len()]; + for _ in 0..gun.pellets { + let stray = arms::stray(&mut self.rng, gun.spread); + let shot = self.shot(self.player, aim, stray, gun.range, passes); + for &t in &shot.struck { + if let Target::Monster(i) = t { + hits[i] += 1; + } + } + let hit = !shot.struck.is_empty(); + self.volley.push(Trace { cells: shot.cells, dmg: weapon.dmg, hit, delay: 0 }); + } + if hits.iter().all(|&n| n == 0) { + self.say(format!("{} misses.", capitalise(&weapon.yours()))); + } + self.echo(); + for (i, n) in hits.into_iter().enumerate() { + if n > 0 && self.monsters[i].hp > 0 { + self.wound(i, &weapon, n, self.player); + } + } + true + } + + /// Gunfire carries through the hull: whatever's asleep within earshot + /// wakes, and comes looking. + fn echo(&mut self) { + let dist = self.map.distances_from(self.player); + let mut stirred = false; + for m in &mut self.monsters { + let near = dist[self.map.index(m.pos)].is_some_and(|d| d <= GUNFIRE_CARRIES); + if !m.awake && near { + m.awake = true; + stirred |= !self.fov.contains(m.pos); + } + } + if stirred { + self.say("The shot rings through the hull. Something stirs.".into()); + } + } + + /// Where a round from `from` goes, aimed at `aim` but straying + /// `stray` degrees: the cells it crosses and what it hits, in order. It + /// stops at a wall, or at the first thing it hits after passing + /// through `passes` others. + fn shot(&self, from: Pos, aim: Pos, stray: f64, range: i32, mut passes: u32) -> Shot { + let mut shot = Shot { cells: Vec::new(), struck: Vec::new() }; + for p in arms::flight(from, aim, stray, range) { + shot.cells.push(p); + if self.map.tile(p).blocks_sight() { + break; + } + let target = if p == self.player { + Some(Target::You) + } else { + self.alive_at(p).map(Target::Monster) + }; + if let Some(t) = target { + shot.struck.push(t); + if passes == 0 { + break; + } + passes -= 1; + } + } + shot + } + + /// The chance that one round from your gun, aimed at `aim`, hits what's + /// there, given what's in the way. `None` if nothing's there. + pub fn odds(&self, aim: Pos) -> Option { + let weapon = &self.gear.gun; + let gun = weapon.gun?; + let i = self.alive_at(aim)?; + let passes = if weapon.dmg == Dmg::Pierce { weapon.effect } else { 0 }; + let hits = (0..ODDS_SAMPLES) + .filter(|&k| { + let u = (f64::from(k) + 0.5) / f64::from(ODDS_SAMPLES); + let stray = arms::stray_quantile(u, gun.spread); + self.shot(self.player, aim, stray, gun.range, passes) + .struck + .contains(&Target::Monster(i)) + }) + .count(); + Some(hits as f64 / f64::from(ODDS_SAMPLES)) + } + + /// The cells a dead-straight shot at `aim` would cross before a wall + /// stops it or it runs out of range. + pub fn line_of_fire(&self, aim: Pos) -> Vec { + let range = self.gear.gun.gun.map_or(0, |g| g.range); + self.shot(self.player, aim, 0.0, range, u32::MAX).cells + } + + /// Whether `aim` is close enough for your gun to reach. + pub fn in_range(&self, aim: Pos) -> bool { + self.gear.gun.gun.is_some_and(|g| within(self.player, aim, g.range)) + } + + /// `hits` blows or rounds from `weapon` land on monster `i`, from the + /// direction of `from`. Rolls the damage, does whatever else the weapon + /// does, and says so. The dead are left for `bury`. + fn wound(&mut self, i: usize, weapon: &Weapon, hits: u32, from: Pos) { + let kind = self.monsters[i].kind; + let name = kind.name; + let affinity = kind.affinity(weapon.dmg); + self.monsters[i].awake = true; + let (mut total, mut crits) = (0, 0); + for _ in 0..hits { + let mut d = self.rng.random_range(weapon.damage.0..=weapon.damage.1); + if self.rng.random_bool(weapon.crit) { + d *= 2; + crits += 1; + } + if affinity != Affinity::Immune { + let armour = if weapon.dmg == Dmg::Pierce { 0 } else { kind.armour }; + total += (affinity.scale(d) - armour).max(1); + } + } + + let subject = capitalise(&weapon.yours()); + let verb = if weapon.is_gun() { "hits" } else { weapon.dmg.verb() }; + let times = match hits { + 1 => String::new(), + 2 => " twice".into(), + n => format!(" {n} times"), + }; + if affinity == Affinity::Immune { + self.say(format!("{subject} {verb} the {name}{times}, to no effect.")); } else { - self.say(format!("You burn the {name} ({damage}).")); + let mut notes = vec![total.to_string()]; + if crits > 0 { + notes.push("critical".into()); + } + match affinity { + Affinity::Weak => notes.push("weak".into()), + Affinity::Resists => notes.push("resisted".into()), + _ => {} + } + self.say(format!("{subject} {verb} the {name}{times} ({}).", notes.join(", "))); + } + if self.known.insert((name, weapon.dmg)) && affinity != Affinity::Normal { + self.say(format!("The {name} {} {}.", affinity.phrase(), weapon.dmg.name())); + } + if total == 0 { + return; + } + self.monsters[i].hp -= total; + if self.monsters[i].hp <= 0 { + self.say(killed(weapon.dmg, name)); + return; + } + + let chance = f64::from(weapon.effect.min(100)) / 100.0; + match weapon.dmg { + Dmg::Cut if weapon.effect > 0 && kind.blood => { + let m = &mut self.monsters[i]; + let fresh = m.bleeding == 0; + m.bleeding = (m.bleeding + weapon.effect * hits).min(MAX_BLEEDING); + if fresh { + self.say(format!("The {name} is bleeding.")); + } + } + Dmg::Burn if weapon.effect > 0 => { + let m = &mut self.monsters[i]; + let fresh = m.burning == 0; + m.burning = m.burning.max(weapon.effect); + if fresh { + self.say(format!("The {name} catches fire.")); + } + } + Dmg::Shock if self.rng.random_bool(chance) => { + self.monsters[i].stunned = 1; + self.say(format!("The {name} seizes up.")); + } + Dmg::Blunt if self.rng.random_bool(chance) => self.knock_back(i, from), + Dmg::Hymn if self.hp < self.max_hp && self.rng.random_bool(chance) => { + let eased = self.rng.random_range(1..=2).min(self.max_hp - self.hp); + self.hp += eased; + self.say(format!("The hymn steadies you (+{eased}).")); + } + _ => {} } } - /// Each awake monster attacks if it's next to you, or else takes one - /// step along the shortest path toward you, opening doors on the way. + /// Knocks monster `i` a cell away from `from`. Into a wall, it staggers. + fn knock_back(&mut self, i: usize, from: Pos) { + let m = &self.monsters[i]; + let (name, at) = (m.kind.name, m.pos); + let to = at.offset((at.x - from.x).signum(), (at.y - from.y).signum()); + let tile = self.map.tile(to); + if tile.walkable() && to != self.player && self.alive_at(to).is_none() { + self.monsters[i].pos = to; + self.say(format!("The {name} is knocked back.")); + } else if tile.blocks_sight() { + let slam = self.rng.random_range(1..=2); + let m = &mut self.monsters[i]; + m.hp -= slam; + m.stunned = 1; + let dead = m.hp <= 0; + self.say(format!("The {name} slams into the bulkhead ({slam}).")); + if dead { + self.say(killed(Dmg::Blunt, name)); + } + } + } + + /// Each awake monster has its turn: shoots if it has a gun and you in + /// its sights, hits you if it's next to you, or else heads toward you + /// along the shortest path, opening doors on the way. fn monsters_act(&mut self) { let dist = self.map.distances_from(self.player); for i in 0..self.monsters.len() { if self.outcome.is_some() { return; } - let monster = &self.monsters[i]; - if !monster.awake { + let m = &self.monsters[i]; + if !m.awake || m.hp <= 0 { + continue; + } + if m.stunned > 0 { + self.monsters[i].stunned -= 1; continue; } - if monster.pos.steps_to(self.player) == 1 { - self.monster_attacks(i); + let (kind, pos) = (m.kind, m.pos); + if let Some(gun) = kind.attack.gun + && within(pos, self.player, gun.range) + && self.fov.contains(pos) + { + self.monster_fires(i, gun); continue; } - let here = dist[self.map.index(monster.pos)].unwrap_or(u32::MAX); - let next = monster - .pos - .neighbours() - .filter(|&n| self.map.contains(n) && self.monster_at(n).is_none()) - .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n))) - .filter(|&(d, _)| d < here) - .min_by_key(|&(d, n)| (d, n.y, n.x)); - if let Some((_, n)) = next { - if self.map.tile(n) == (Tile::Door { open: false }) { - self.map.set(n, Tile::Door { open: true }); - } else { - self.monsters[i].pos = n; + for _ in 0..kind.speed { + if self.monsters[i].pos.steps_to(self.player) == 1 { + self.monster_attacks(i); + break; + } + if kind.slow && self.turns.is_multiple_of(2) { + break; + } + if !self.monster_steps(i, &dist) { + break; } } } } + /// Takes one step toward you, or opens a door in the way. Says whether + /// it went anywhere. + fn monster_steps(&mut self, i: usize, dist: &[Option]) -> bool { + let pos = self.monsters[i].pos; + let here = dist[self.map.index(pos)].unwrap_or(u32::MAX); + let next = pos + .neighbours() + .filter(|&n| self.map.contains(n) && self.alive_at(n).is_none()) + .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n))) + .filter(|&(d, _)| d < here) + .min_by_key(|&(d, n)| (d, n.y, n.x)); + let Some((_, n)) = next else { return false }; + if self.map.tile(n) == (Tile::Door { open: false }) { + self.map.set(n, Tile::Door { open: true }); + } else { + self.monsters[i].pos = n; + } + true + } + fn monster_attacks(&mut self, i: usize) { - let kind = self.monsters[i].kind; - if !self.rng.random_bool(MONSTER_HIT) { + let (kind, from) = (self.monsters[i].kind, self.monsters[i].pos); + let attack = &kind.attack; + if !self.rng.random_bool(attack.hit) { self.say(format!("The {} misses you.", kind.name)); return; } - let damage = self.rng.random_range(kind.damage.0..=kind.damage.1); + let damage = self.rng.random_range(attack.damage.0..=attack.damage.1); + let (damage, shown, worn) = self.blunted(damage, attack.dmg); + self.say(format!("The {} {} you ({shown}).", kind.name, kind.verb)); + self.hurt(damage, kind.name); + self.aftermath(kind, from); + for line in worn { + self.say(line); + } + } + + /// What a blow from `kind`, struck from `from`, leaves you with besides + /// the hurt: a wound that bleeds unless your armour keeps it closed, + /// flames if it burns, a shove if it's heavy. + fn aftermath(&mut self, kind: &'static MonsterKind, from: Pos) { + let attack = &kind.attack; + if self.outcome.is_some() { + return; + } + if attack.bleed > 0 { + // Part of the same blow: it wears no more for it. + if let Some(a) = self.gear.worn.values().find(|a| a.stanching()) { + self.say(format!("{} keeps the wound closed.", capitalise(&a.yours()))); + } else { + if self.bleeding == 0 { + self.say("You're bleeding.".into()); + } + self.bleeding = (self.bleeding + attack.bleed).min(MAX_BLEEDING); + self.bled_by = kind.name; + } + } + if attack.ignite > 0 { + if self.burning == 0 { + self.say("You catch alight.".into()); + } + self.burning = self.burning.max(attack.ignite); + self.burned_by = kind.name; + } + if attack.knock > 0.0 && self.rng.random_bool(attack.knock) { + self.knocked(from); + } + } + + /// Knocks you a cell away from `from`. Into a bulkhead, or anything else + /// in the way, you reel and lose your next turn. + fn knocked(&mut self, from: Pos) { + let at = self.player; + let to = at.offset((at.x - from.x).signum(), (at.y - from.y).signum()); + let tile = self.map.tile(to); + match self.alive_at(to) { + None if tile.walkable() => { + self.player = to; + self.say("You're knocked back.".into()); + } + Some(j) => { + let name = self.monsters[j].kind.name; + self.reeling = true; + self.say(format!("You're knocked into the {name}.")); + } + None => { + self.reeling = true; + self.say("You're knocked into the bulkhead.".into()); + } + } + } + + fn monster_fires(&mut self, i: usize, gun: Gun) { + let (kind, from) = (self.monsters[i].kind, self.monsters[i].pos); + if self.monsters[i].loaded == 0 { + self.monsters[i].loaded = gun.magazine; + self.say(format!("The {} reloads.", kind.name)); + return; + } + self.monsters[i].loaded -= 1; + let stray = arms::stray(&mut self.rng, gun.spread); + let shot = self.shot(from, self.player, stray, gun.range, 0); + let delay = self.volley.iter().map(|t| t.delay + t.cells.len() as u32 + 2).max(); + let attack = &kind.attack; + let hit = !shot.struck.is_empty(); + let struck = shot.struck.first().copied(); + self.volley.push(Trace { + cells: shot.cells, + dmg: attack.dmg, + hit, + delay: delay.unwrap_or(0), + }); + let damage = self.rng.random_range(attack.damage.0..=attack.damage.1); + match struck { + Some(Target::You) => { + let (damage, shown, worn) = self.blunted(damage, attack.dmg); + self.say(format!("The {} {} you ({shown}).", kind.name, kind.verb)); + self.hurt(damage, kind.name); + self.aftermath(kind, from); + for line in worn { + self.say(line); + } + } + Some(Target::Monster(j)) => { + let other = self.monsters[j].kind; + let affinity = other.affinity(attack.dmg); + let damage = if affinity == Affinity::Immune { 0 } else { affinity.scale(damage) }; + let m = &mut self.monsters[j]; + m.hp -= damage; + m.awake = true; + let dead = m.hp <= 0; + self.say(format!("The {}'s shot hits the {} ({damage}).", kind.name, other.name)); + if dead { + self.say(format!("The {} goes down.", other.name)); + } + } + None => self.say(format!("The {}'s shot goes wide.", kind.name)), + } + } + + /// What a blow of `damage` does to you through what you're wearing: + /// each piece takes off what it guards against `dmg`, though a blow + /// always does at least 1, and each piece that softened it wears a + /// little. Returns what's left, how to show it ("2", or "pierce 2 - 1 = + /// 1"), and anything to say about armour wearing thin or out. + fn blunted(&mut self, damage: i32, dmg: Dmg) -> (i32, String, Vec) { + let mut left = damage; + let mut news = Vec::new(); + for a in self.gear.worn.values_mut() { + let off = a.guard(dmg).min(left - 1); + if off > 0 { + left -= off; + news.extend(a.wear_down()); + } + } + let shown = if left < damage { + format!("{} {damage} - {} = {left}", dmg.name(), damage - left) + } else { + damage.to_string() + }; + (left, shown, news) + } + + fn hurt(&mut self, damage: i32, by: &'static str) { self.hp = (self.hp - damage).max(0); - self.say(format!("The {} {} you ({damage}).", kind.name, kind.verb)); - if self.hp == 0 { - self.outcome = Some(Outcome::Died { killer: kind.name }); + if self.hp == 0 && self.outcome.is_none() { + self.outcome = Some(Outcome::Died { killer: by }); self.say(format!("You die aboard {}.", self.name)); } } + + /// After your move each turn, wounds bleed and fires burn, a little + /// less each time. Yours too: a wound you take on their move bleeds on + /// your next, unless a medpatch stops it first, and flames burn until + /// they go out or a medpatch smothers them, your burn guards taking + /// their share. + fn fester(&mut self) { + for i in 0..self.monsters.len() { + let m = &mut self.monsters[i]; + if m.hp <= 0 || (m.bleeding == 0 && m.burning == 0) { + continue; + } + let (mut bled, mut burned) = (0, 0); + if m.bleeding > 0 { + bled = m.bleeding as i32; + m.bleeding -= 1; + } + if m.burning > 0 { + burned = m.kind.affinity(Dmg::Burn).scale(self.rng.random_range(1..=2)); + m.burning -= 1; + } + m.hp -= bled + burned; + let (name, dead, pos) = (m.kind.name, m.hp <= 0, m.pos); + if dead { + let how = if burned > 0 { "burns down to slag" } else { "bleeds out" }; + self.say(format!("The {name} {how}.")); + } else if self.fov.contains(pos) { + let mut what = Vec::new(); + if bled > 0 { + what.push(format!("bleeds ({bled})")); + } + if burned > 0 { + what.push(format!("burns ({burned})")); + } + self.say(format!("The {name} {}.", what.join(" and "))); + } + } + if self.bleeding > 0 && self.outcome.is_none() { + let bled = self.bleeding as i32; + self.bleeding -= 1; + let clots = if self.bleeding == 0 { " The wound clots." } else { "" }; + self.say(format!("You bleed ({bled}).{clots}")); + self.hurt(bled, self.bled_by); + } + if self.burning > 0 && self.outcome.is_none() { + self.burning -= 1; + let heat = self.rng.random_range(1..=2); + let (burned, shown, worn) = self.blunted(heat, Dmg::Burn); + let out = if self.burning == 0 { " The flames go out." } else { "" }; + self.say(format!("You burn ({shown}).{out}")); + self.hurt(burned, self.burned_by); + for line in worn { + self.say(line); + } + } + } +} + +/// Whether `b` is within `range` cells of `a`, measured the way sight and +/// shots are. +fn within(a: Pos, b: Pos, range: i32) -> bool { + let (dx, dy) = (b.x - a.x, b.y - a.y); + dx * dx + dy * dy <= range * range + range +} + +fn killed(dmg: Dmg, name: &str) -> String { + match dmg { + Dmg::Cut => format!("You cut the {name} down."), + Dmg::Blunt => format!("You smash the {name} to pieces."), + Dmg::Pierce => format!("The {name} drops, holed through."), + Dmg::Burn => format!("You burn the {name} down to slag."), + Dmg::Shock => format!("The {name} jerks and goes still."), + Dmg::Hymn => format!("The {name} falls silent."), + } +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) } #[cfg(test)] @@ -344,7 +1372,71 @@ mod tests { Action::UseMedpatch, ]; for _ in 0..turns { - crawl.act(ACTIONS[rng.random_range(..ACTIONS.len())]); + let action = match rng.random_range(0..20) { + 0 => Action::Reload, + 1 => Action::PickUp, + 2 => Action::Ready(rng.random_range(0..8)), + 3 => { + let spot = match rng.random_range(0..4) { + 0 => Spot::Melee, + 1 => Spot::Gun, + 2 => Spot::Worn(Slot::Head), + _ => Spot::Pack(rng.random_range(0..8)), + }; + Action::Drop(spot, rng.random_range(1..=3)) + } + 4 | 5 => { + let p = crawl.player; + Action::Fire(p.offset(rng.random_range(-9..=9), rng.random_range(-9..=9))) + } + _ => ACTIONS[rng.random_range(..ACTIONS.len())], + }; + crawl.act(action); + } + } + + /// A crawl in an empty room 30 wide and 9 high, standing at (2, 5) + /// with nothing else aboard. + fn arena() -> Crawl { + let mut crawl = Crawl::new(1); + let mut map = Map::new(32, 11, Tile::Hull); + for p in map.positions().collect::>() { + if (1..31).contains(&p.x) && (1..10).contains(&p.y) { + map.set(p, Tile::Floor); + } + } + crawl.seen = vec![false; (map.width * map.height) as usize]; + crawl.map = map; + crawl.player = Pos::new(2, 5); + crawl.monsters.clear(); + crawl.loot.clear(); + crawl.look(); + crawl + } + + /// Puts an awake `name` at `pos`. + fn spawn(crawl: &mut Crawl, name: &str, pos: Pos) -> usize { + let kind = MONSTERS.iter().find(|k| k.name == name).unwrap(); + let mut m = Monster::new(kind, pos); + m.awake = true; + crawl.monsters.push(m); + crawl.monsters.len() - 1 + } + + /// A weapon that always lands, doing exactly `damage`. + fn sure(dmg: Dmg, damage: i32, effect: u32) -> Weapon { + let mut w = arms::cutting_torch(); + w.kit = false; + w.title = "test weapon".into(); + (w.dmg, w.damage, w.accuracy, w.crit, w.effect) = (dmg, (damage, damage), 1.0, 0.0, effect); + w + } + + fn wield(crawl: &mut Crawl, w: Weapon) { + if w.is_gun() { + crawl.gear.gun = w; + } else { + crawl.gear.melee = w; } } @@ -356,9 +1448,13 @@ mod tests { assert!(crawl.hp >= 0 && crawl.hp <= crawl.max_hp); assert!(crawl.map.tile(crawl.player).walkable(), "seed {seed}: player in a wall"); for m in &crawl.monsters { + assert!(m.hp > 0, "seed {seed}: a dead {} is still about", m.kind.name); assert!(crawl.map.tile(m.pos).walkable(), "seed {seed}: {} in a wall", m.kind.name); assert_ne!(m.pos, crawl.player); } + let g = &crawl.gear; + assert!(!g.melee.is_gun() && g.gun.is_gun(), "seed {seed}: the wrong things ready"); + assert!(g.carried() <= g.capacity, "seed {seed}: carrying too much"); if crawl.outcome.is_none() { assert!(crawl.fov.contains(crawl.player)); } @@ -390,7 +1486,9 @@ mod tests { let mut crawl = Crawl::new(5); let next_to_player = crawl.player.neighbours().find(|&n| crawl.map.tile(n).walkable()); let pos = next_to_player.unwrap(); - crawl.monsters = vec![Monster { kind: &MONSTERS[0], pos, hp: 3, awake: true }]; + let mut mite = Monster::new(&MONSTERS[0], pos); + mite.awake = true; + crawl.monsters = vec![mite]; let (dx, dy) = (pos.x - crawl.player.x, pos.y - crawl.player.y); for _ in 0..50 { crawl.act(Action::Move(dx, dy)); @@ -413,4 +1511,430 @@ mod tests { crawl.act(Action::Move(wall.x - crawl.player.x, wall.y - crawl.player.y)); assert_eq!(crawl.turns, 0); } + + #[test] + fn guns_kill_at_range_and_need_reloading() { + let mut crawl = arena(); + spawn(&mut crawl, "hollow crewman", Pos::new(7, 5)); + let magazine = crawl.gear.gun.gun.unwrap().magazine; + for _ in 0..magazine { + let aim = crawl.monsters.first().map_or(Pos::new(7, 5), |m| m.pos); + crawl.act(Action::Fire(aim)); + } + assert_eq!(crawl.gear.gun.loaded, 0); + assert!(!crawl.volley.is_empty() || crawl.monsters.is_empty()); + let turns = crawl.turns; + crawl.act(Action::Fire(Pos::new(7, 5))); + assert_eq!((crawl.turns, crawl.gear.gun.loaded), (turns + 1, magazine), "reloads"); + for _ in 0..40 { + let Some(aim) = crawl.monsters.first().map(|m| m.pos) else { break }; + crawl.act(Action::Fire(aim)); + } + assert!(crawl.monsters.is_empty(), "the crewman survived 40 rivets"); + assert!(crawl.known.contains(&("hollow crewman", Dmg::Pierce))); + assert!(crawl.log.iter().any(|m| m == "The hollow crewman resists pierce.")); + } + + #[test] + fn some_things_shrug_some_damage_off() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "choir-wraith", Pos::new(5, 5)); + crawl.monsters[i].stunned = 1000; + for _ in 0..4 { + crawl.act(Action::Fire(Pos::new(5, 5))); + } + assert_eq!(crawl.monsters[0].hp, 14, "rivets go through a wraith"); + assert!(crawl.log.iter().any(|m| m == "The choir-wraith is immune to pierce.")); + assert_eq!(crawl.knows(crawl.monsters[0].kind, Dmg::Pierce), Some(Affinity::Immune)); + assert_eq!(crawl.knows(crawl.monsters[0].kind, Dmg::Hymn), None); + } + + #[test] + fn piercing_rounds_go_through_and_ignore_armour() { + let mut crawl = arena(); + for x in [5, 6] { + let i = spawn(&mut crawl, "bulkhead sentry", Pos::new(x, 5)); + crawl.monsters[i].stunned = 1000; + } + let mut rail = sure(Dmg::Pierce, 3, 1); + rail.gun = Some(Gun { range: 10, spread: 0.0, pellets: 1, magazine: 9 }); + rail.loaded = 9; + wield(&mut crawl, rail); + crawl.act(Action::Fire(Pos::new(5, 5))); + let hp: Vec = crawl.monsters.iter().map(|m| m.hp).collect(); + assert_eq!(hp, [5, 5], "3 through armour 2, and on into the next"); + } + + #[test] + fn armour_takes_its_share() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "bulkhead sentry", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Blunt, 4, 0)); + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].hp, 6, "4 less armour 2"); + } + + #[test] + fn cuts_bleed_and_bleeding_runs_its_course() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "hollow crewman", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Cut, 1, 3)); + crawl.act(Action::Move(1, 0)); + // 1 weak is 2, then 3 bled at the end of the turn. + assert_eq!((crawl.monsters[0].hp, crawl.monsters[0].bleeding), (4, 2)); + crawl.act(Action::Wait); + crawl.act(Action::Wait); + crawl.act(Action::Wait); + assert_eq!((crawl.monsters[0].hp, crawl.monsters[0].bleeding), (1, 0)); + } + + #[test] + fn machines_dont_bleed() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Cut, 2, 3)); + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].bleeding, 0); + } + + #[test] + fn blows_knock_things_back_and_into_walls() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Blunt, 1, 100)); + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].pos, Pos::new(4, 5)); + + crawl.player = Pos::new(29, 5); + crawl.monsters[0].pos = Pos::new(30, 5); + let hp = crawl.monsters[0].hp; + crawl.act(Action::Move(1, 0)); + assert_eq!(crawl.monsters[0].pos, Pos::new(30, 5), "nowhere to go"); + assert!(crawl.monsters[0].hp < hp - 1, "it hit the wall too"); + assert!(crawl.log.iter().any(|m| m.contains("slams into the bulkhead"))); + } + + #[test] + fn shocks_stun() { + let mut crawl = arena(); + spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + crawl.hp = 100; + crawl.max_hp = 100; + wield(&mut crawl, sure(Dmg::Shock, 1, 100)); + crawl.act(Action::Move(1, 0)); + assert!(!crawl.log.iter().any(|m| m.contains("hound savages") || m.contains("misses you"))); + assert!(crawl.log.iter().any(|m| m.contains("seizes up"))); + } + + #[test] + fn fire_burns_on() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + crawl.monsters[i].stunned = 1000; + wield(&mut crawl, sure(Dmg::Burn, 1, 2)); + crawl.act(Action::Move(1, 0)); + // 1 weak is 2, then 1–2 weak is 2–3 for the burning. + let hp = crawl.monsters[0].hp; + assert!((1..=2).contains(&hp), "hp {hp}"); + assert_eq!(crawl.monsters[0].burning, 1); + } + + #[test] + fn claws_make_you_bleed_and_medpatches_stop_it() { + let mut crawl = arena(); + spawn(&mut crawl, "hollow crewman", Pos::new(3, 5)); + crawl.hp = 1000; + crawl.max_hp = 1000; + for _ in 0..20 { + crawl.act(Action::Wait); + if crawl.bleeding > 0 { + break; + } + } + assert!(crawl.bleeding > 0, "twenty turns of claws and not a scratch"); + crawl.monsters.clear(); + crawl.gear.add(Stack::one(Item::Medpatch)); + crawl.act(Action::UseMedpatch); + assert_eq!(crawl.bleeding, 0); + } + + #[test] + fn sentries_shoot_from_where_they_stand() { + let mut crawl = arena(); + spawn(&mut crawl, "bulkhead sentry", Pos::new(8, 5)); + crawl.hp = 1000; + crawl.max_hp = 1000; + for _ in 0..10 { + crawl.act(Action::Wait); + } + assert_eq!(crawl.monsters[0].pos, Pos::new(8, 5)); + assert!(crawl.hp < 1000, "ten turns under fire and not a scratch"); + assert!(crawl.volley.iter().any(|t| t.cells.contains(&crawl.player))); + } + + #[test] + fn hounds_run_two_steps_a_turn() { + let mut crawl = arena(); + spawn(&mut crawl, "bilge-hound", Pos::new(12, 5)); + crawl.act(Action::Wait); + assert_eq!(crawl.monsters[0].pos.steps_to(crawl.player), 8); + } + + #[test] + fn the_odds_fall_off_with_distance() { + let mut pistol = sure(Dmg::Pierce, 1, 0); + pistol.gun = Some(Gun { range: 20, spread: 10.0, pellets: 1, magazine: 6 }); + let odds_at = |x: i32| { + let mut crawl = arena(); + wield(&mut crawl, pistol.clone()); + spawn(&mut crawl, "rust-mite", Pos::new(x, 5)); + assert!(crawl.odds(Pos::new(x, 6)).is_none(), "nothing there"); + crawl.odds(Pos::new(x, 5)).unwrap() + }; + let odds = [3, 6, 12, 20].map(odds_at); + assert_eq!(odds[0], 1.0, "point blank"); + assert!(odds.windows(2).all(|w| w[0] > w[1]), "{odds:?}"); + + // Something in the way takes the hit. + let mut crawl = arena(); + spawn(&mut crawl, "rust-mite", Pos::new(4, 5)); + spawn(&mut crawl, "rust-mite", Pos::new(6, 5)); + assert!(crawl.odds(Pos::new(6, 5)).unwrap() < 0.5); + } + + #[test] + fn you_carry_only_what_you_have_room_for() { + let mut crawl = arena(); + let mut rng = seed::rng(1); + for _ in 0..12 { + let mut w = arms::find(&mut rng, arms::Source::Armoury, false); + w.mass = 4000; + crawl.loot.push(Loot::new(Pos::new(3, 5), Item::Weapon(w))); + } + crawl.act(Action::Move(1, 0)); + let room = crawl.gear.capacity - Gear::default().carried(); + let fit = (room / 4000) as usize; + assert_eq!(crawl.loot.len(), 12 - fit); + assert!(crawl.gear.carried() <= crawl.gear.capacity); + assert!(crawl.log.iter().any(|m| m.contains("too much to carry"))); + crawl.act(Action::Take(0, 1)); + assert_eq!(crawl.loot.len(), 12 - fit, "still no room"); + assert!(crawl.log.last().unwrap().contains("no room")); + } + + #[test] + fn stacks_come_and_go_by_the_count() { + let mut crawl = arena(); + crawl.gear.add(Stack { item: Item::Medpatch, count: 5 }); + crawl.act(Action::Drop(Spot::Pack(0), 2)); + assert_eq!(crawl.gear.medpatches(), 3); + assert_eq!(crawl.loot[0].stack.count, 2); + crawl.act(Action::Take(0, 1)); + assert_eq!(crawl.gear.medpatches(), 4); + assert_eq!(crawl.loot[0].stack.count, 1); + assert_eq!(crawl.turns, 1, "dropping is free; picking up takes a turn"); + } + + #[test] + fn what_you_put_down_stays_down() { + let mut crawl = arena(); + wield(&mut crawl, sure(Dmg::Cut, 3, 0)); + crawl.gear.add(Stack::one(Item::Weapon(arms::cutting_torch()))); + crawl.act(Action::Drop(Spot::Melee, 1)); + assert_eq!(crawl.turns, 0, "dropping takes no time"); + assert_eq!(crawl.gear.melee.title, "cutting torch", "back to the torch"); + assert!(crawl.gear.pack.is_empty()); + crawl.act(Action::Move(1, 0)); + crawl.act(Action::Move(-1, 0)); + assert!(crawl.gear.pack.is_empty(), "walking over it leaves it"); + crawl.act(Action::PickUp); + assert_eq!(crawl.gear.pack.len(), 1); + + crawl.act(Action::Drop(Spot::Gun, 1)); + assert!(crawl.gear.gun.kit, "with nothing else to shoot with, you keep it"); + assert!(crawl.log.last().unwrap().contains("nothing to shoot with")); + } + + #[test] + fn readying_takes_a_turn_and_picks_the_right_hand() { + let mut crawl = arena(); + crawl.gear.add(Stack::one(Item::Weapon(sure(Dmg::Cut, 3, 0)))); + let mut gun = sure(Dmg::Pierce, 2, 0); + gun.gun = Some(Gun { range: 6, spread: 4.0, pellets: 1, magazine: 3 }); + crawl.gear.add(Stack::one(Item::Weapon(gun))); + crawl.act(Action::Ready(0)); + assert_eq!(crawl.gear.melee.title, "test weapon"); + // The torch went where the blade was; the gun is now second. + crawl.act(Action::Ready(0)); + assert!(crawl.gear.gun.is_gun() && !crawl.gear.gun.kit); + assert_eq!(crawl.turns, 2); + crawl.act(Action::Ready(9)); + assert_eq!(crawl.turns, 2, "nothing there"); + } + + /// A piece of armour on your body guarding `guards`, with `durability` + /// of 10. + fn plate(guards: &[(Dmg, i32)], durability: u32, stanches: bool) -> items::Armor { + items::Armor { + title: "test plate".into(), + description: String::new(), + slot: Slot::Body, + guards: guards.iter().copied().collect(), + stanches, + durability, + max_durability: 10, + condition: arms::Condition::Sound, + named: false, + pair: false, + mass: 1, + } + } + + /// What the armour you wear on your body has left. + fn left(crawl: &Crawl) -> u32 { + crawl.gear.worn[&Slot::Body].durability + } + + #[test] + fn armour_takes_its_share_of_blows_of_its_kind_and_wears() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 10, false)); + spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + for _ in 0..10 { + crawl.act(Action::Wait); + } + let blows = crawl.log.iter().filter(|l| l.contains("zaps you")).count() as i32; + let softened = crawl.log.iter().filter(|l| l.contains("zaps you (shock")).count() as u32; + assert!(blows > 0 && crawl.hp == crawl.max_hp - blows, "a point a blow: {:?}", crawl.log); + assert!(softened > 0, "{:?}", crawl.log); + assert_eq!(left(&crawl), 10 - softened, "a point of wear for each blow it softened"); + + // Against another kind of blow it does nothing, and doesn't wear. + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 10, false)); + spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + for _ in 0..4 { + crawl.act(Action::Wait); + } + assert!(crawl.log.iter().any(|l| l.contains("savages you ("))); + assert!(!crawl.log.iter().any(|l| l.contains("savages you (cut"))); + assert_eq!(left(&crawl), 10); + } + + #[test] + fn worn_out_armour_guards_nothing_but_stays_on() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 1, false)); + spawn(&mut crawl, "maintenance drone", Pos::new(3, 5)); + for _ in 0..20 { + crawl.act(Action::Wait); + if left(&crawl) == 0 { + break; + } + } + assert!(crawl.log.iter().any(|l| l.contains("worn out")), "{:?}", crawl.log); + assert!(crawl.gear.worn.contains_key(&Slot::Body)); + assert_eq!(crawl.gear.guard(Dmg::Shock), 0); + } + + #[test] + fn welders_set_you_alight_from_a_few_cells_off() { + let mut crawl = arena(); + spawn(&mut crawl, "welder drone", Pos::new(5, 5)); + for _ in 0..20 { + crawl.act(Action::Wait); + if crawl.burning > 0 { + break; + } + } + assert_eq!(crawl.burning, 2, "{:?}", crawl.log); + assert!(crawl.log.iter().any(|l| l.starts_with("The welder drone torches you ("))); + assert!(crawl.log.iter().any(|l| l == "You catch alight.")); + assert_eq!(crawl.monsters[0].pos, Pos::new(5, 5), "it sprays from where it is"); + + // It burns on after, until it goes out or a medpatch smothers it. + crawl.monsters.clear(); + let hp = crawl.hp; + crawl.act(Action::Wait); + assert!(crawl.hp < hp && crawl.log.last().unwrap().starts_with("You burn (")); + assert_eq!(crawl.burning, 1); + crawl.gear.add(Stack::one(Item::Medpatch)); + crawl.act(Action::UseMedpatch); + assert_eq!(crawl.burning, 0); + assert!(crawl.log.iter().any(|l| l.contains("smothers the flames"))); + } + + #[test] + fn burn_guards_take_their_share_of_the_flames() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Burn, 100)], 10, false)); + crawl.burning = 6; + crawl.burned_by = "welder drone"; + let hp = crawl.hp; + for _ in 0..6 { + crawl.act(Action::Wait); + } + assert_eq!(crawl.hp, hp - 6, "1 a turn: {:?}", crawl.log); + assert!(crawl.log.last().unwrap().ends_with("The flames go out.")); + let softened = crawl.log.iter().filter(|l| l.starts_with("You burn (burn 2")).count(); + assert_eq!(left(&crawl), 10 - softened as u32, "worn by every tick it softened"); + } + + #[test] + fn loaders_lumber_and_knock_you_about() { + let mut crawl = arena(); + let i = spawn(&mut crawl, "cargo loader", Pos::new(8, 5)); + for _ in 0..4 { + crawl.act(Action::Wait); + } + assert_eq!(crawl.monsters[i].pos.x, 6, "a step every other turn"); + + // Beside you, it hits every turn, and knocks you back to the wall. + crawl.monsters[i].pos = Pos::new(3, 5); + (crawl.hp, crawl.max_hp) = (1000, 1000); + let (mut knocked, mut reeled) = (false, false); + for _ in 0..40 { + let turns = crawl.turns; + crawl.act(Action::Wait); + knocked |= crawl.log.iter().any(|l| l == "You're knocked back."); + if crawl.log.iter().any(|l| l.contains("lose a turn")) { + reeled = true; + assert!(crawl.log.iter().any(|l| l == "You're knocked into the bulkhead.")); + assert_eq!(crawl.turns, turns + 2, "you lose a turn while it has another"); + break; + } + } + assert!(knocked && reeled, "{:?}", crawl.log); + assert_eq!(crawl.player, Pos::new(1, 5), "up against the hull"); + assert!(crawl.log.iter().any(|l| l.starts_with("The cargo loader slams you ("))); + } + + #[test] + fn being_alight_is_saved() { + let mut crawl = Crawl::new(5); + (crawl.burning, crawl.burned_by) = (2, "welder drone"); + let back = Crawl::resume(&crawl.saved()).unwrap(); + assert_eq!((back.burning, back.burned_by), (2, "welder drone")); + let mut saved = crawl.saved(); + saved.burned_by = "something long gone".into(); + assert_eq!(Crawl::resume(&saved).unwrap().burning, 0); + } + + #[test] + fn armour_that_stanches_keeps_cuts_from_bleeding() { + let mut crawl = arena(); + crawl.gear.worn.insert(Slot::Body, plate(&[], 10, true)); + spawn(&mut crawl, "bilge-hound", Pos::new(3, 5)); + for _ in 0..6 { + crawl.act(Action::Wait); + } + assert!(crawl.hp < crawl.max_hp, "the bites still land"); + assert!(crawl.log.iter().any(|l| l.contains("keeps the wound closed"))); + assert_eq!(crawl.bleeding, 0); + assert_eq!(left(&crawl), 10, "it softened no blow, so it didn't wear"); + } } diff --git a/src/dungeon/mod.rs b/src/dungeon/mod.rs index d3227a5..baeb0fe 100644 --- a/src/dungeon/mod.rs +++ b/src/dungeon/mod.rs @@ -1,11 +1,15 @@ //! Turn-based dungeons: the map, field of view, derelict-ship generation, //! and the rules of a crawl. Drawing lives in `scenes::derelict`. +pub mod armour; +pub mod arms; pub mod crawl; pub mod fov; pub mod ship; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Pos { pub x: i32, pub y: i32, @@ -111,6 +115,14 @@ impl Map { self.tiles[i] = tile; } + /// Which room `p` is in, to tell when you've gone into another. + pub fn room_index(&self, p: Pos) -> Option { + if !self.contains(p) { + return None; + } + self.room_of[self.index(p)] + } + pub fn room_at(&self, p: Pos) -> Option<&Room> { if !self.contains(p) { return None; diff --git a/src/dungeon/ship.rs b/src/dungeon/ship.rs index dc9b0b8..d845376 100644 --- a/src/dungeon/ship.rs +++ b/src/dungeon/ship.rs @@ -25,8 +25,11 @@ use rand::RngExt; use rand::seq::IndexedRandom; -use super::crawl::{Item, Loot, MONSTERS, Monster}; +use super::armour; +use super::arms::{self, Source}; +use super::crawl::{Loot, MONSTERS, Monster}; use super::{Map, Pos, Tile}; +use crate::items::{Item, Salvage}; use crate::names::{self, Culture}; use crate::seed::{self, Rng}; @@ -52,42 +55,65 @@ struct RoomKind { salvage: (u32, u32), /// Chance of a medpatch. medpatch: f64, + /// Chance of a weapon; at 1, there are one or two, and in better shape. + arms: f64, + /// What sort of weapon it'd be, or armour. + source: Source, + /// Chance of a piece of armour; at 1, one or two, in better shape. + armour: f64, } -const ENGINE_ROOM: RoomKind = - RoomKind { name: "the engine room", weight: 0, artifact: 0.05, salvage: (2, 3), medpatch: 0.1 }; -const BRIDGE: RoomKind = - RoomKind { name: "the bridge", weight: 0, artifact: 0.6, salvage: (0, 2), medpatch: 0.1 }; +const ENGINE_ROOM: RoomKind = RoomKind { + name: "the engine room", + weight: 0, + artifact: 0.05, + salvage: (2, 3), + medpatch: 0.1, + arms: 0.3, + source: Source::Tools, + armour: 0.15, +}; +const BRIDGE: RoomKind = RoomKind { + name: "the bridge", + weight: 0, + artifact: 0.6, + salvage: (0, 2), + medpatch: 0.1, + arms: 0.3, + source: Source::Armoury, + armour: 0.2, +}; #[rustfmt::skip] const ROOMS: &[RoomKind] = &[ - RoomKind { name: "a cargo hold", weight: 4, artifact: 0.05, salvage: (2, 4), medpatch: 0.05 }, - RoomKind { name: "the crew berths", weight: 3, artifact: 0.1, salvage: (0, 1), medpatch: 0.25 }, - RoomKind { name: "the galley", weight: 2, artifact: 0.02, salvage: (0, 2), medpatch: 0.1 }, - RoomKind { name: "the med-bay", weight: 1, artifact: 0.05, salvage: (0, 1), medpatch: 1.0 }, - RoomKind { name: "a machine shop", weight: 2, artifact: 0.05, salvage: (1, 3), medpatch: 0.0 }, - RoomKind { name: "the armoury", weight: 1, artifact: 0.15, salvage: (1, 2), medpatch: 0.3 }, - RoomKind { name: "a cryo-vault", weight: 1, artifact: 0.3, salvage: (0, 1), medpatch: 0.2 }, - RoomKind { name: "the chapel", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 }, - RoomKind { name: "the reliquary", weight: 1, artifact: 0.9, salvage: (0, 0), medpatch: 0.0 }, - RoomKind { name: "the archive", weight: 1, artifact: 0.4, salvage: (0, 1), medpatch: 0.0 }, - RoomKind { name: "the navigator's cell", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 }, - RoomKind { name: "an observation blister", weight: 1, artifact: 0.1, salvage: (0, 1), medpatch: 0.0 }, + RoomKind { name: "a cargo hold", weight: 4, artifact: 0.05, salvage: (2, 4), medpatch: 0.05, arms: 0.2, source: Source::Tools, armour: 0.1 }, + RoomKind { name: "the crew berths", weight: 3, artifact: 0.1, salvage: (0, 1), medpatch: 0.25, arms: 0.3, source: Source::Crew, armour: 0.4 }, + RoomKind { name: "the galley", weight: 2, artifact: 0.02, salvage: (0, 2), medpatch: 0.1, arms: 0.2, source: Source::Crew, armour: 0.05 }, + RoomKind { name: "the med-bay", weight: 1, artifact: 0.05, salvage: (0, 1), medpatch: 1.0, arms: 0.1, source: Source::Tools, armour: 0.05 }, + RoomKind { name: "a machine shop", weight: 2, artifact: 0.05, salvage: (1, 3), medpatch: 0.0, arms: 0.5, source: Source::Tools, armour: 0.2 }, + RoomKind { name: "the armoury", weight: 1, artifact: 0.15, salvage: (1, 2), medpatch: 0.3, arms: 1.0, source: Source::Armoury, armour: 1.0 }, + RoomKind { name: "a cryo-vault", weight: 1, artifact: 0.3, salvage: (0, 1), medpatch: 0.2, arms: 0.15, source: Source::Crew, armour: 0.15 }, + RoomKind { name: "the chapel", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0, arms: 0.4, source: Source::Rites, armour: 0.35 }, + RoomKind { name: "the reliquary", weight: 1, artifact: 0.9, salvage: (0, 0), medpatch: 0.0, arms: 0.5, source: Source::Rites, armour: 0.3 }, + RoomKind { name: "the archive", weight: 1, artifact: 0.4, salvage: (0, 1), medpatch: 0.0, arms: 0.1, source: Source::Rites, armour: 0.05 }, + RoomKind { name: "the navigator's cell", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0, arms: 0.2, source: Source::Crew, armour: 0.15 }, + RoomKind { name: "an observation blister", weight: 1, artifact: 0.1, salvage: (0, 1), medpatch: 0.0, arms: 0.15, source: Source::Armoury, armour: 0.1 }, ]; -/// Salvage and its base value; each find varies by ±30%. -const SALVAGE: &[(&str, u32)] = &[ - ("a coil of superconductor", 30), - ("a cracked reactor baffle", 45), - ("a crate of ration bricks", 10), - ("an intact navigation core", 90), - ("a spool of wiring", 15), - ("a stack of hull plates", 25), - ("a sealed canister of coolant", 20), - ("a working servo", 35), - ("a box of votive candles", 8), - ("a cantor's data-slate", 40), - ("a half-charged fuel cell", 50), +/// Salvage: what it's called, its base value, and what it weighs in grams. +/// Each find's value varies by ±30%. +const SALVAGE: &[(&str, u32, u32)] = &[ + ("coil of superconductor", 30, 6000), + ("cracked reactor baffle", 45, 9000), + ("crate of ration bricks", 10, 8000), + ("intact navigation core", 90, 5000), + ("spool of wiring", 15, 3000), + ("stack of hull plates", 25, 12000), + ("sealed canister of coolant", 20, 7000), + ("working servo", 35, 4000), + ("box of votive candles", 8, 2000), + ("cantor's data-slate", 40, 500), + ("half-charged fuel cell", 50, 6000), ]; /// A room as generated, before its contents are placed. @@ -146,13 +172,14 @@ pub fn generate(seed: u64) -> Derelict { let kind = plan.kind; if rng.random_bool(kind.artifact) { let pos = *cells.choose(&mut rng).unwrap(); - loot.push(Loot { pos, item: Item::Artifact(names::artifact(&mut rng)) }); + loot.push(Loot::new(pos, Item::Artifact(names::artifact(&mut rng)))); } for _ in 0..rng.random_range(kind.salvage.0..=kind.salvage.1) { let pos = *cells.choose(&mut rng).unwrap(); - let &(name, base) = SALVAGE.choose(&mut rng).unwrap(); + let &(title, base, mass) = SALVAGE.choose(&mut rng).unwrap(); let value = (f64::from(base) * rng.random_range(0.7..1.3)).round() as u32; - loot.push(Loot { pos, item: Item::Salvage { name, value } }); + let salvage = Salvage { title: title.into(), value, mass }; + loot.push(Loot::new(pos, Item::Salvage(salvage))); } let medpatches = if kind.medpatch >= 1.0 { rng.random_range(1..=2) @@ -160,18 +187,18 @@ pub fn generate(seed: u64) -> Derelict { u32::from(rng.random_bool(kind.medpatch)) }; for _ in 0..medpatches { - loot.push(Loot { pos: *cells.choose(&mut rng).unwrap(), item: Item::Medpatch }); + loot.push(Loot::new(*cells.choose(&mut rng).unwrap(), Item::Medpatch)); } } // Every derelict is worth boarding: if no room rolled an artifact, put // one as far from the airlock as the ship goes. - if !loot.iter().any(|l| matches!(l.item, Item::Artifact(_))) { + if !loot.iter().any(|l| matches!(l.stack.item, Item::Artifact(_))) { let farthest = map .positions() .filter(|&p| free(&map, p)) .max_by_key(|&p| (dist[map.index(p)], p.x, p.y)) .expect("a derelict always has floor"); - loot.push(Loot { pos: farthest, item: Item::Artifact(names::artifact(&mut rng)) }); + loot.push(Loot::new(farthest, Item::Artifact(names::artifact(&mut rng)))); } let lairs: Vec = map @@ -188,7 +215,51 @@ pub fn generate(seed: u64) -> Derelict { if kind.unique && monsters.iter().any(|m| m.kind.name == kind.name) { continue; } - monsters.push(Monster { kind, pos, hp: kind.hp, awake: false }); + monsters.push(Monster::new(kind, pos)); + } + + // Weapons roll dice of their own, so arming the derelicts left every + // ship's rooms, loot and lairs where they were. + let mut rng = seed::rng(seed::derive(seed, "arms", 0)); + let mut armed = false; + for plan in &plans { + let cells: Vec = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect(); + if cells.is_empty() { + continue; + } + let kind = plan.kind; + let stocked = kind.arms >= 1.0; + let n = + if stocked { rng.random_range(1..=2) } else { u32::from(rng.random_bool(kind.arms)) }; + for _ in 0..n { + let pos = *cells.choose(&mut rng).unwrap(); + let weapon = arms::find(&mut rng, kind.source, stocked); + loot.push(Loot::new(pos, Item::Weapon(weapon))); + armed = true; + } + } + // And there's always something to fight with aboard. + if !armed && let Some(plan) = plans.iter().find(|p| p.cells.iter().any(|&c| free(&map, c))) { + let cells: Vec = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect(); + let pos = *cells.choose(&mut rng).unwrap(); + loot.push(Loot::new(pos, Item::Weapon(arms::find(&mut rng, plan.kind.source, false)))); + } + + // Armour too: armouries and berths mostly, warded pieces in chapels. + let mut rng = seed::rng(seed::derive(seed, "armour", 0)); + for plan in &plans { + let cells: Vec = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect(); + if cells.is_empty() { + continue; + } + let kind = plan.kind; + let stocked = kind.armour >= 1.0; + let n = + if stocked { rng.random_range(1..=2) } else { u32::from(rng.random_bool(kind.armour)) }; + for _ in 0..n { + let pos = *cells.choose(&mut rng).unwrap(); + loot.push(Loot::new(pos, Item::Armor(armour::find(&mut rng, kind.source, stocked)))); + } } Derelict { name, description, map, start, monsters, loot } @@ -436,12 +507,26 @@ mod tests { assert!(map.tile(l.pos).walkable(), "seed {seed}: loot in a wall"); assert_ne!(l.pos, d.start); } - assert!(d.loot.iter().any(|l| matches!(l.item, Item::Artifact(_)))); + assert!(d.loot.iter().any(|l| matches!(l.stack.item, Item::Artifact(_)))); + assert!( + d.loot.iter().any(|l| matches!(l.stack.item, Item::Weapon(_))), + "seed {seed}: unarmed" + ); assert!(map.rooms.len() >= 4, "seed {seed}: only {} rooms", map.rooms.len()); assert!(d.monsters.iter().filter(|m| m.kind.unique).count() <= 1); } } + #[test] + fn armour_turns_up_in_most_wrecks() { + let armoured = (0..200) + .filter(|&seed| { + generate(seed).loot.iter().any(|l| matches!(l.stack.item, Item::Armor(_))) + }) + .count(); + assert!((80..195).contains(&armoured), "armour aboard {armoured} of 200 wrecks"); + } + #[test] fn same_seed_same_ship() { let (a, b) = (generate(99), generate(99)); -- cgit v1.2.3