diff options
| author | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
| commit | 9ccf951473e21a8b90c25161156fa09a9b227a7b (patch) | |
| tree | e7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/dungeon/crawl.rs | |
| parent | d36c54d996f3c14ae811312dbbe621e12a39edde (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/crawl.rs')
| -rw-r--r-- | src/dungeon/crawl.rs | 1692 |
1 files changed, 1608 insertions, 84 deletions
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"); + } } |