summaryrefslogtreecommitdiff
path: root/src/dungeon/arms.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/dungeon/arms.rs')
-rw-r--r--src/dungeon/arms.rs679
1 files changed, 679 insertions, 0 deletions
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));
+ }
+}