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