summaryrefslogtreecommitdiff
path: root/src/dungeon/crawl.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/dungeon/crawl.rs')
-rw-r--r--src/dungeon/crawl.rs1692
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");
+ }
}