From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/dungeon/arms.rs | 679 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 679 insertions(+) create mode 100644 src/dungeon/arms.rs (limited to 'src/dungeon/arms.rs') diff --git a/src/dungeon/arms.rs b/src/dungeon/arms.rs new file mode 100644 index 0000000..d37e1c8 --- /dev/null +++ b/src/dungeon/arms.rs @@ -0,0 +1,679 @@ +//! Weapons: the kinds of damage, the kinds of weapon, the ones you find +//! aboard wrecks (each with its own name and stats), and the geometry of a +//! shot. The rules for using them live in `crawl`. +//! +//! A found weapon is a kind (a cleaver, a scattergun...) rolled up with a +//! condition: worn things hit softer, fine ones carry an affix ("serrated", +//! "long-barrelled"), and the rare relic carries two and a name of its own. + +use std::fmt::Write as _; + +use rand::RngExt; +use rand::seq::IndexedRandom; +use serde::{Deserialize, Serialize}; + +use super::Pos; +use crate::names::{self, Culture, pick}; +use crate::seed::Rng; + +type Rgb = (u8, u8, u8); + +/// The kinds of damage. Each does something besides hurting (see `about`), +/// and every monster takes each kind differently. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Dmg { + Cut, + Blunt, + Pierce, + Burn, + Shock, + Hymn, +} + +impl Dmg { + pub const ALL: [Dmg; 6] = [Dmg::Cut, Dmg::Blunt, Dmg::Pierce, Dmg::Burn, Dmg::Shock, Dmg::Hymn]; + + pub fn name(self) -> &'static str { + match self { + Dmg::Cut => "cut", + Dmg::Blunt => "blunt", + Dmg::Pierce => "pierce", + Dmg::Burn => "burn", + Dmg::Shock => "shock", + Dmg::Hymn => "hymn", + } + } + + pub fn rgb(self) -> Rgb { + match self { + Dmg::Cut => (230, 90, 90), + Dmg::Blunt => (200, 170, 130), + Dmg::Pierce => (200, 205, 215), + Dmg::Burn => (255, 150, 60), + Dmg::Shock => (110, 200, 255), + Dmg::Hymn => (200, 150, 255), + } + } + + /// What it does besides the damage. + pub fn about(self) -> &'static str { + match self { + Dmg::Cut => "Cuts make the living bleed, a little less each turn.", + Dmg::Blunt => "Blows knock things back; into a bulkhead, they stagger.", + Dmg::Pierce => "Goes through armour; fast rounds go on into what's behind.", + Dmg::Burn => "Sets things alight for a few turns.", + Dmg::Shock => "Can stun: the target loses its next turn.", + Dmg::Hymn => "Old songs against what shouldn't be. Can steady you.", + } + } + + /// "slashes", as in "your cleaver slashes the rust-mite". + pub fn verb(self) -> &'static str { + match self { + Dmg::Cut => "slashes", + Dmg::Blunt => "smashes", + Dmg::Pierce => "stabs", + Dmg::Burn => "burns", + Dmg::Shock => "jolts", + Dmg::Hymn => "rings against", + } + } +} + +/// What makes a weapon a gun. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gun { + /// How far a shot flies, in cells. + pub range: i32, + /// How far a shot can stray either side of where you aim, in degrees. + /// Most shots stray less than this; up close it barely matters. + pub spread: f64, + /// Projectiles per shot. Each strays on its own. + pub pellets: u32, + /// Shots before you have to reload. + pub magazine: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Weapon { + /// What it's called, without an article: "serrated cleaver", or for a + /// relic its own name, "Khaseem's Mercy". + pub title: String, + /// What it is: "a fine serrated cleaver". For a relic, the only place + /// that says what kind of thing it is. + pub description: String, + pub dmg: Dmg, + pub damage: (i32, i32), + /// For blows, the chance one lands. Guns ignore it: where a shot lands + /// is down to spread. + pub accuracy: f64, + /// The chance a hit lands twice as hard. + pub crit: f64, + /// How strongly its damage's extra takes hold: bleeding added, turns + /// alight, % chance to knock back, % to stun, things a round passes + /// through, % chance to steady you. See `effect_text`. + pub effect: u32, + pub gun: Option, + /// Rounds left in the magazine. + pub loaded: u32, + /// Part of your own kit, not something you found: never dropped, and + /// never lost. + pub kit: bool, + /// A relic with its own name. + pub named: bool, + pub condition: Condition, + /// What it weighs, in grams. Saves from before weight have none, and + /// `mend` estimates it. + #[serde(default)] + pub mass: u32, +} + +impl Weapon { + /// "a serrated cleaver", "Khaseem's Mercy". + pub fn name(&self) -> String { + if self.named { + self.title.clone() + } else if self.kit { + format!("your {}", self.title) + } else { + names::a(&self.title) + } + } + + /// "your serrated cleaver", "Khaseem's Mercy": the subject of a sentence. + pub fn yours(&self) -> String { + if self.named { self.title.clone() } else { format!("your {}", self.title) } + } + + pub fn is_gun(&self) -> bool { + self.gun.is_some() + } + + /// "bleeds 2", "knockback 35%"; nothing if it does nothing extra. + pub fn effect_text(&self) -> Option { + let n = self.effect; + if n == 0 { + return None; + } + Some(match self.dmg { + Dmg::Cut => format!("bleeds {n}"), + Dmg::Blunt => format!("knockback {n}%"), + Dmg::Pierce => format!("passes through {n}"), + Dmg::Burn => format!("ignites {n}"), + Dmg::Shock => format!("stuns {n}%"), + Dmg::Hymn => format!("steadies {n}%"), + }) + } + + /// "cut 3–6", the damage. + pub fn damage_text(&self) -> String { + let (lo, hi) = self.damage; + let pellets = self.gun.map_or(1, |g| g.pellets); + let times = if pellets > 1 { format!("×{pellets}") } else { String::new() }; + let damage = if lo == hi { lo.to_string() } else { format!("{lo}–{hi}") }; + format!("{} {damage}{times}", self.dmg.name()) + } + + /// Every stat, for the pack screen: "cut 3–6 · hits 80% · crit 5%". + pub fn stats(&self) -> Vec { + let mut first = vec![self.damage_text()]; + match self.gun { + Some(g) => { + first.push(format!("range {}", g.range)); + first.push(format!("spread ±{}°", trim(g.spread))); + } + None => first.push(format!("hits {}%", percent(self.accuracy))), + } + let mut second = Vec::new(); + if let Some(g) = self.gun { + second.push(format!("{}/{} loaded", self.loaded, g.magazine)); + } + second.push(format!("crit {}%", percent(self.crit))); + second.extend(self.effect_text()); + vec![first.join(" · "), second.join(" · ")] + } + + pub fn reload(&mut self) { + if let Some(g) = self.gun { + self.loaded = g.magazine; + } + } + + /// Puts right whatever a save file got wrong: numbers that make sense, + /// and a weight for a weapon saved before things had one. + pub fn mend(&mut self) { + self.damage.0 = self.damage.0.max(1); + self.damage.1 = self.damage.1.max(self.damage.0); + self.accuracy = self.accuracy.clamp(0.0, 1.0); + self.crit = self.crit.clamp(0.0, 1.0); + if let Some(g) = &mut self.gun { + g.range = g.range.max(1); + g.pellets = g.pellets.clamp(1, 12); + g.magazine = g.magazine.max(1); + g.spread = if g.spread.is_finite() { g.spread.clamp(0.0, 90.0) } else { 0.0 }; + self.loaded = self.loaded.min(g.magazine); + } + if self.mass == 0 { + self.mass = self.estimated_mass(); + } + } + + /// What a weapon saved without a weight probably weighs: its kind's, + /// going by what it's called. + fn estimated_mass(&self) -> u32 { + let own = [cutting_torch(), rivet_gun()]; + if self.kit { + return own.iter().find(|w| w.title == self.title).map_or(2000, |w| w.mass); + } + KINDS + .iter() + .find(|k| { + k.nouns.iter().any(|n| self.description.contains(n) || self.title.contains(n)) + }) + .map_or(if self.is_gun() { 3000 } else { 1500 }, |k| k.mass) + } +} + +fn percent(p: f64) -> i32 { + (p * 100.0).round() as i32 +} + +/// 1.5 as "1.5", 6.0 as "6". +fn trim(x: f64) -> String { + let s = format!("{x:.1}"); + s.strip_suffix(".0").map_or(s.clone(), str::to_string) +} + +/// Where a kind of weapon turns up most. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Source { + /// Engine rooms, holds, machine shops. + Tools, + /// Armouries and bridges. + Armoury, + /// Berths and galleys: what the crew kept to hand. + Crew, + /// Chapels and reliquaries. + Rites, +} + +struct Kind { + /// What it might be called; one is picked for each weapon found. + nouns: &'static [&'static str], + dmg: Dmg, + damage: (i32, i32), + accuracy: f64, + effect: u32, + gun: Option, + /// How common it is, relative to the others. + weight: u32, + /// Where it's most likely to turn up. + found: &'static [Source], + /// What one weighs, in grams. + mass: u32, +} + +const fn gun(range: i32, spread: f64, pellets: u32, magazine: u32) -> Option { + Some(Gun { range, spread, pellets, magazine }) +} + +use Source::{Armoury, Crew, Rites, Tools}; + +#[rustfmt::skip] +const KINDS: &[Kind] = &[ + // Blows: walk into something to use them. + Kind { nouns: &["cleaver", "hacking-blade", "machete"], dmg: Dmg::Cut, damage: (3, 6), accuracy: 0.80, effect: 1, gun: None, weight: 4, found: &[Crew, Tools], mass: 1200 }, + Kind { nouns: &["shiv", "boarding knife", "galley knife"], dmg: Dmg::Cut, damage: (1, 4), accuracy: 0.95, effect: 2, gun: None, weight: 4, found: &[Crew], mass: 300 }, + Kind { nouns: &["bone-saw", "chain-blade", "rip-saw"], dmg: Dmg::Cut, damage: (2, 7), accuracy: 0.70, effect: 3, gun: None, weight: 2, found: &[Tools], mass: 1800 }, + Kind { nouns: &["crowbar", "pry-bar", "spanner"], dmg: Dmg::Blunt, damage: (2, 6), accuracy: 0.80, effect: 35, gun: None, weight: 5, found: &[Tools], mass: 1600 }, + Kind { nouns: &["bulkhead maul", "sledge", "breaching hammer"], dmg: Dmg::Blunt, damage: (4, 9), accuracy: 0.65, effect: 70, gun: None, weight: 2, found: &[Tools, Armoury], mass: 5000 }, + Kind { nouns: &["boarding spike", "stiletto", "awl"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 0.85, effect: 0, gun: None, weight: 3, found: &[Armoury, Crew], mass: 500 }, + Kind { nouns: &["thermal lance", "plasma cutter", "heat-knife"], dmg: Dmg::Burn, damage: (3, 7), accuracy: 0.75, effect: 2, gun: None, weight: 2, found: &[Tools, Armoury], mass: 2500 }, + Kind { nouns: &["arc baton", "shock-prod", "stun-maul"], dmg: Dmg::Shock, damage: (2, 4), accuracy: 0.85, effect: 35, gun: None, weight: 3, found: &[Armoury], mass: 1100 }, + Kind { nouns: &["censer-flail", "chime-staff", "bell-hammer"], dmg: Dmg::Hymn, damage: (2, 5), accuracy: 0.80, effect: 30, gun: None, weight: 2, found: &[Rites], mass: 2200 }, + Kind { nouns: &["tuning-blade", "psalter's sword", "cantor's knife"], dmg: Dmg::Hymn, damage: (3, 6), accuracy: 0.75, effect: 20, gun: None, weight: 1, found: &[Rites], mass: 1400 }, + // Guns: `f` to aim and fire. + Kind { nouns: &["slug pistol", "service pistol", "hand-cannon"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 1.0, effect: 0, gun: gun(7, 6.0, 1, 6), weight: 4, found: &[Armoury, Crew], mass: 1100 }, + Kind { nouns: &["rail rifle", "long-gun", "coilgun"], dmg: Dmg::Pierce, damage: (4, 8), accuracy: 1.0, effect: 1, gun: gun(12, 1.5, 1, 2), weight: 1, found: &[Armoury], mass: 4500 }, + Kind { nouns: &["scattergun", "blunderbuss", "grapeshot pistol"], dmg: Dmg::Blunt, damage: (1, 3), accuracy: 1.0, effect: 15, gun: gun(5, 16.0, 5, 2), weight: 3, found: &[Armoury, Crew], mass: 3200 }, + Kind { nouns: &["flamer", "incinerator", "torch-gun"], dmg: Dmg::Burn, damage: (1, 3), accuracy: 1.0, effect: 3, gun: gun(4, 12.0, 3, 3), weight: 2, found: &[Tools, Armoury], mass: 6000 }, + Kind { nouns: &["arc caster", "spark-thrower", "coil projector"], dmg: Dmg::Shock, damage: (2, 5), accuracy: 1.0, effect: 40, gun: gun(5, 3.0, 1, 3), weight: 2, found: &[Armoury, Tools], mass: 3500 }, + Kind { nouns: &["needler", "flechette gun", "dart pistol"], dmg: Dmg::Cut, damage: (1, 2), accuracy: 1.0, effect: 1, gun: gun(7, 8.0, 3, 4), weight: 3, found: &[Crew, Armoury], mass: 1300 }, + Kind { nouns: &["harpoon gun", "bolt-thrower", "spear-gun"], dmg: Dmg::Cut, damage: (3, 7), accuracy: 1.0, effect: 2, gun: gun(6, 4.0, 1, 1), weight: 2, found: &[Tools], mass: 3800 }, + Kind { nouns: &["psalm-horn", "choir-lantern", "hymnal projector"], dmg: Dmg::Hymn, damage: (2, 6), accuracy: 1.0, effect: 25, gun: gun(6, 9.0, 1, 3), weight: 2, found: &[Rites], mass: 2600 }, +]; + +/// Base chance of a critical hit. +const CRIT: f64 = 0.05; + +fn kit(title: &'static str, dmg: Dmg, damage: (i32, i32), gun: Option, mass: u32) -> Weapon { + Weapon { + title: title.to_string(), + description: format!("your own {title}"), + dmg, + damage, + accuracy: 0.8, + crit: CRIT, + effect: 0, + loaded: gun.map_or(0, |g| g.magazine), + gun, + kit: true, + named: false, + condition: Condition::Sound, + mass, + } +} + +/// The torch you cut your way aboard with. It's also what you hit things with +/// until you find better. +pub fn cutting_torch() -> Weapon { + kit("cutting torch", Dmg::Burn, (2, 5), None, 2200) +} + +/// For pinning hull plates; it does for rust-mites, too. +pub fn rivet_gun() -> Weapon { + kit("rivet gun", Dmg::Pierce, (1, 3), gun(6, 5.0, 1, 3), 1800) +} + +/// What a weapon's condition did to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Condition { + Worn, + Sound, + Fine, + Relic, +} + +/// A weapon found where `source` things turn up. `better` shifts the odds +/// toward finer ones: an armoury keeps its stock in better shape than a +/// galley does. +pub fn find(rng: &mut Rng, source: Source, better: bool) -> Weapon { + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if k.found.contains(&source) { 5 } else { 1 }) + .expect("there are weapon kinds"); + let roll = rng.random_range(0..100) + if better { 25 } else { 0 }; + let condition = match roll { + ..25 => Condition::Worn, + 25..65 => Condition::Sound, + 65..94 => Condition::Fine, + _ => Condition::Relic, + }; + forge(rng, kind, condition) +} + +/// A weapon for an armourer's rack where `culture` lives. Each people +/// favours its own sort: Qaari blades and spear-guns, Cant shock and burn +/// tools, High House duelling pieces in fine condition, Drift guns. A rack +/// keeps nothing too worn to sell; a `relic` is rare and dear. +pub fn stocked(rng: &mut Rng, culture: Culture, relic: bool) -> Weapon { + let favoured = |k: &Kind| match culture { + Culture::Qaari => k.dmg == Dmg::Cut, + Culture::Cant => matches!(k.dmg, Dmg::Shock | Dmg::Burn), + Culture::Houses => matches!(k.dmg, Dmg::Pierce | Dmg::Hymn), + Culture::Drift => k.gun.is_some(), + }; + let kind = KINDS + .choose_weighted(rng, |k| k.weight * if favoured(k) { 4 } else { 1 }) + .expect("there are weapon kinds"); + let fine = if culture == Culture::Houses { 20 } else { 0 }; + let condition = match rng.random_range(0..100) + fine { + _ if relic => Condition::Relic, + ..15 => Condition::Worn, + 15..70 => Condition::Sound, + _ => Condition::Fine, + }; + forge(rng, kind, condition) +} + +/// What a weapon's worth to an armourer, in scrip: more for more damage, a +/// surer hit, a longer reach and a stronger extra, and a good deal more for +/// a fine one or a relic. +pub fn value(w: &Weapon) -> u32 { + let (lo, hi) = w.damage; + let pellets = w.gun.map_or(1, |g| g.pellets); + let damage = f64::from(lo + hi) / 2.0 * f64::from(pellets); + let aim = match w.gun { + Some(g) => (0.6 + f64::from(g.range.min(12)) / 30.0) * (1.0 - g.spread.min(45.0) / 60.0), + None => w.accuracy, + }; + let n = f64::from(w.effect); + let extra = match w.dmg { + Dmg::Cut | Dmg::Burn => n * 0.8, + Dmg::Pierce => n, + Dmg::Blunt => n / 25.0, + Dmg::Shock | Dmg::Hymn => n / 20.0, + }; + let power = (damage * aim + extra) * (1.0 + 1.5 * w.crit); + let condition = match w.condition { + Condition::Worn => 0.8, + Condition::Sound => 1.0, + Condition::Fine => 1.25, + Condition::Relic => 3.0, + }; + (5.0 * power.powf(1.5) * condition).round().max(1.0) as u32 +} + +fn forge(rng: &mut Rng, kind: &Kind, condition: Condition) -> Weapon { + let noun = pick(kind.nouns, rng); + let mut w = Weapon { + title: String::new(), + description: String::new(), + dmg: kind.dmg, + damage: kind.damage, + accuracy: kind.accuracy, + crit: CRIT, + effect: kind.effect, + gun: kind.gun, + loaded: 0, + kit: false, + named: false, + condition, + mass: kind.mass, + }; + // No two quite alike. + w.damage.1 = (w.damage.1 + rng.random_range(-1..=1)).max(w.damage.0); + if !w.is_gun() { + w.accuracy += rng.random_range(-0.05..0.05); + } + if let Some(g) = &mut w.gun { + g.spread *= rng.random_range(0.85..1.15); + g.spread = (g.spread * 2.0).round() / 2.0; + } + + let mut words = Vec::new(); + match condition { + Condition::Worn => { + scale(&mut w, 0.75); + w.accuracy -= 0.05; + words.push(pick(WORN, rng)); + } + Condition::Sound => {} + Condition::Fine => words.extend(affixes(rng, &mut w, 1)), + Condition::Relic => { + scale(&mut w, 1.25); + w.accuracy += 0.05; + words.extend(affixes(rng, &mut w, 2)); + } + } + w.accuracy = w.accuracy.clamp(0.4, 0.98); + w.crit = w.crit.min(0.5); + w.reload(); + + let mut what = words.join(" "); + if !what.is_empty() { + what.push(' '); + } + what.push_str(noun); + w.description = names::a(&what); + if condition == Condition::Relic { + w.title = relic_name(rng); + w.named = true; + } else { + w.title = what; + } + w +} + +/// Scales the damage, keeping it at least 1–1. +fn scale(w: &mut Weapon, k: f64) { + let s = |x: i32| ((f64::from(x) * k).round() as i32).max(1); + w.damage = (s(w.damage.0), s(w.damage.1).max(s(w.damage.0))); +} + +/// Up to `n` different affixes that suit the weapon, applied; returns their +/// words. +fn affixes(rng: &mut Rng, w: &mut Weapon, n: usize) -> Vec<&'static str> { + let mut words = Vec::new(); + for _ in 0..n { + let fits: Vec<&Affix> = + AFFIXES.iter().filter(|a| (a.fits)(w) && !words.contains(&a.word)).collect(); + let Some(affix) = fits.choose(rng) else { break }; + (affix.apply)(w); + words.push(affix.word); + } + words +} + +struct Affix { + word: &'static str, + fits: fn(&Weapon) -> bool, + apply: fn(&mut Weapon), +} + +fn melee(w: &Weapon) -> bool { + !w.is_gun() +} + +fn gun_mut(w: &mut Weapon) -> &mut Gun { + w.gun.as_mut().expect("affix only fits guns") +} + +#[rustfmt::skip] +const AFFIXES: &[Affix] = &[ + Affix { word: "serrated", fits: |w| w.dmg == Dmg::Cut, apply: |w| w.effect += 1 }, + Affix { word: "keen", fits: |w| melee(w) && matches!(w.dmg, Dmg::Cut | Dmg::Pierce), apply: |w| w.crit += 0.15 }, + Affix { word: "heavy", fits: melee, apply: |w| { w.damage.0 += 1; w.damage.1 += 2; w.accuracy -= 0.1 } }, + Affix { word: "balanced", fits: melee, apply: |w| w.accuracy += 0.1 }, + Affix { word: "weighted", fits: |w| w.dmg == Dmg::Blunt, apply: |w| w.effect += 20 }, + Affix { word: "searing", fits: |w| w.dmg == Dmg::Burn, apply: |w| { w.effect += 1; w.damage.1 += 1 } }, + Affix { word: "overcharged", fits: |w| w.dmg == Dmg::Shock, apply: |w| { w.effect += 20; w.damage.1 += 1 } }, + Affix { word: "hallowed", fits: |w| w.dmg == Dmg::Hymn, apply: |w| { w.effect += 20; w.damage.0 += 1; w.damage.1 += 1 } }, + Affix { word: "long-barrelled", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { let g = gun_mut(w); g.range += 3; g.spread *= 0.6 } }, + Affix { word: "rifled", fits: |w| w.gun.is_some_and(|g| g.spread > 2.0), apply: |w| gun_mut(w).spread *= 0.7 }, + Affix { word: "drum-fed", fits: |w| w.gun.is_some(), apply: |w| { let g = gun_mut(w); g.magazine += g.magazine.div_ceil(2) + 1 } }, + Affix { word: "sawn-off", fits: |w| w.gun.is_some_and(|g| g.pellets > 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 1; let g = gun_mut(w); g.spread *= 1.3; g.range -= 1 } }, + Affix { word: "tungsten-cored", fits: |w| w.dmg == Dmg::Pierce && w.gun.is_some(), apply: |w| w.effect += 1 }, + Affix { word: "high-calibre", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 2; let g = gun_mut(w); g.magazine = g.magazine.saturating_sub(1).max(1) } }, +]; + +/// How a worn weapon looks. +const WORN: &[&str] = &[ + "pitted", "battered", "rust-flecked", "taped-up", "bent", "scorched", "chipped", "patched", + "grimy", "cracked", +]; + +pub(crate) fn relic_name(rng: &mut Rng) -> String { + let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())]; + match rng.random_range(0..5) { + 0 | 1 => format!("{}'s {}", culture.word(rng), pick(VIRTUES, rng)), + 2 | 3 => format!("the {}", pick(TITLES, rng)), + _ => format!("the {} of {}", pick(VIRTUES, rng), culture.place(rng)), + } +} + +const VIRTUES: &[&str] = &[ + "Mercy", "Answer", "Penance", "Tithe", "Benediction", "Reply", "Grief", "Promise", "Wake", + "Lullaby", "Verdict", "Vigil", "Absolution", "Welcome", "Errand", "Patience", "Due", +]; + +const TITLES: &[&str] = &[ + "Quiet Argument", "Last Rites", "Patient Reply", "Red Ledger", "Cold Comfort", "Small Mercy", + "Iron Vow", "Long Silence", "Bright Answer", "Hungry Saint", "Second Opinion", "Final Offer", + "Kind Word", "Open Door", "Ninth Psalm", "Short Sermon", "Closing Hymn", "Widow's Dowry", + "Unpaid Debt", "Late Guest", +]; + +/// How far a shot strays, in degrees: most of the time near the middle, +/// never past `spread` either side. +pub fn stray(rng: &mut Rng, spread: f64) -> f64 { + spread * (rng.random_range(0.0..1.0) - rng.random_range(0.0..1.0)) +} + +/// The stray at fraction `u` of the way through all strays, smallest first: +/// `stray` is equally likely to land in any of `n` evenly spaced slices of +/// these. For working out the odds of a shot without rolling dice. +pub fn stray_quantile(u: f64, spread: f64) -> f64 { + if u < 0.5 { + spread * (-1.0 + (2.0 * u).sqrt()) + } else { + spread * (1.0 - (2.0 * (1.0 - u)).sqrt()) + } +} + +/// The cells a shot from `from` crosses, aimed at the middle of `aim` but +/// straying `deviation` degrees, out to `range` cells. Doesn't include +/// `from`, and knows nothing of walls; the caller stops it where it hits. +pub fn flight(from: Pos, aim: Pos, deviation: f64, range: i32) -> Vec { + let (dx, dy) = (f64::from(aim.x - from.x), f64::from(aim.y - from.y)); + if dx == 0.0 && dy == 0.0 { + return Vec::new(); + } + let angle = dy.atan2(dx) + deviation.to_radians(); + let (cos, sin) = (angle.cos(), angle.sin()); + let (x0, y0) = (f64::from(from.x) + 0.5, f64::from(from.y) + 0.5); + // Into every cell whose edge is within `range` and a half of the middle + // of `from`, a twentieth of a cell at a time. + let steps = (f64::from(range) + 0.5) * 20.0; + let mut cells: Vec = Vec::new(); + for i in 1..steps as i32 { + let t = f64::from(i) / 20.0; + let p = Pos::new((x0 + cos * t).floor() as i32, (y0 + sin * t).floor() as i32); + if p != from && cells.last() != Some(&p) { + cells.push(p); + } + } + cells +} + +/// A few lines describing `n` weapons found in a run of wrecks, for +/// `orrery weapons`. +pub fn catalogue(rng: &mut Rng, n: usize) -> String { + let mut out = String::new(); + let sources = [Tools, Armoury, Crew, Rites]; + for i in 0..n { + let w = find(rng, sources[i % sources.len()], false); + let _ = match w.named { + true => writeln!(out, "{} ({})", w.name(), w.description), + false => writeln!(out, "{}", w.name()), + }; + for line in w.stats() { + let _ = writeln!(out, " {line}"); + } + let _ = writeln!(out, " {:.1} kg", f64::from(w.mass) / 1000.0); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed; + + #[test] + fn found_weapons_are_sound() { + let mut rng = seed::rng(4); + let mut titles = std::collections::HashSet::new(); + for i in 0..2000 { + let source = [Tools, Armoury, Crew, Rites][i % 4]; + let w = find(&mut rng, source, i % 3 == 0); + assert!(w.damage.0 >= 1 && w.damage.0 <= w.damage.1, "{w:?}"); + assert!((0.4..=0.98).contains(&w.accuracy), "{w:?}"); + assert!(!w.title.is_empty() && !w.description.is_empty()); + if let Some(g) = w.gun { + assert!(g.range >= 3 && g.magazine >= 1 && g.pellets >= 1 && g.spread > 0.0); + assert_eq!(w.loaded, g.magazine, "found loaded"); + } + assert!(w.mass > 0); + // A save from before weight gets the same weight back. + let mut saved = w.clone(); + saved.mass = 0; + saved.mend(); + assert_eq!(saved.mass, w.mass, "{}", w.description); + titles.insert(w.title.clone()); + } + assert!(titles.len() > 300, "only {} different weapons", titles.len()); + } + + #[test] + fn every_kind_of_damage_turns_up() { + let mut rng = seed::rng(5); + let found: Vec = + (0..400).map(|_| find(&mut rng, Source::Armoury, false).dmg).collect(); + for dmg in Dmg::ALL { + assert!(found.contains(&dmg), "never found a {} weapon", dmg.name()); + } + } + + #[test] + fn shots_fly_straight_when_they_dont_stray() { + let from = Pos::new(2, 2); + let path = flight(from, Pos::new(6, 2), 0.0, 4); + assert_eq!(path, (3..=6).map(|x| Pos::new(x, 2)).collect::>()); + let diagonal = flight(from, Pos::new(5, 5), 0.0, 4); + assert_eq!(diagonal[..3], [Pos::new(3, 3), Pos::new(4, 4), Pos::new(5, 5)]); + assert!(flight(from, from, 0.0, 5).is_empty()); + } + + #[test] + fn strays_stay_within_the_spread() { + let mut rng = seed::rng(6); + for _ in 0..1000 { + assert!(stray(&mut rng, 8.0).abs() <= 8.0); + } + assert_eq!(stray_quantile(0.5, 8.0), 0.0); + assert!(stray_quantile(0.0, 8.0) <= -7.99 && stray_quantile(1.0, 8.0) >= 7.99); + assert!(stray_quantile(0.25, 8.0) < stray_quantile(0.3, 8.0)); + } +} -- cgit v1.2.3