//! 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::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; 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, } 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, /// 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. pub weight: u32, /// At most one aboard any ship. 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, 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 { pub kind: &'static MonsterKind, pub pos: Pos, 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, } 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 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)] pub enum Outcome { /// Back through the airlock, with everything carried. Escaped, Died { killer: &'static str, }, } #[derive(Clone, Copy, Debug)] 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, 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, struck: Vec, } pub struct Crawl { /// What the ship and its dice are made from. pub seed: u64, /// "the Vexant". pub name: String, /// "Cant hauler". pub description: String, pub map: Map, pub player: Pos, pub hp: i32, pub max_hp: i32, pub monsters: Vec, pub loot: Vec, /// 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, pub fov: Fov, /// Cells you've seen at some point, drawn from memory when out of sight. seen: Vec, /// Messages, oldest first. pub log: Vec, /// How many messages there have been, however many the log still holds. pub said: u32, pub turns: u32, pub outcome: Option, /// 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, loot: Vec, 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, /// Every cell you've seen, in hex, four cells to a digit. #[serde(default)] seen: String, #[serde(default)] log: Vec, #[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 { let mut cells: Vec = 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, map: ship.map, player: ship.start, hp: PLAYER_HP, max_hp: PLAYER_HP, monsters: ship.monsters, loot: ship.loot, 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)); crawl.look(); 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 { 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 && m.hp > 0) } fn alive_at(&self, p: Pos) -> Option { 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 { (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 { 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(); } pub fn say(&mut self, message: String) { self.log.push(message); self.said += 1; if self.log.len() > LOG_LENGTH { self.log.remove(0); } } /// Recomputes what you can see, remembers it, and wakes anything that's /// now in view (and so, by symmetry, can see you). fn look(&mut self) { self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS); for p in self.map.positions() { if self.fov.contains(p) { let i = self.map.index(p); self.seen[i] = true; } } let mut woken = Vec::new(); for m in &mut self.monsters { if !m.awake && self.fov.contains(m.pos) { m.awake = true; woken.push(m.kind.name); } } for name in woken { self.say(format!("A {name} stirs and turns toward you.")); } } /// 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.alive_at(target) { self.strike(i); return true; } match self.map.tile(target) { Tile::Door { open: false } => { self.map.set(target, Tile::Door { open: true }); self.say("The door grinds open.".into()); true } Tile::Airlock => { self.outcome = Some(Outcome::Escaped); self.say("You cycle the airlock and cast off.".into()); true } tile if tile.walkable() => { self.player = target; self.pick_up(false); true } _ => false, } } /// 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.gear.medpatches() == 0 { self.say("You have no medpatches.".into()); return false; } if self.hp == self.max_hp && self.bleeding == 0 && self.burning == 0 { self.say("You're not hurt.".into()); return false; } self.gear.take_medpatch(); let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp); self.hp += 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) -> 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 } /// 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(weapon.accuracy) { let name = self.monsters[i].kind.name; self.say(format!("{} misses the {name}.", capitalise(&weapon.yours()))); return; } 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 { 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 { 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 { 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}).")); } _ => {} } } /// 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 m = &self.monsters[i]; if !m.awake || m.hp <= 0 { continue; } if m.stunned > 0 { self.monsters[i].stunned -= 1; continue; } 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; } 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]) -> 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, 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(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) { 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); 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)] mod tests { use super::*; /// A bot that wanders at random, for shaking out panics and broken rules. fn wander(crawl: &mut Crawl, rng: &mut Rng, turns: usize) { const ACTIONS: [Action; 10] = [ Action::Move(-1, -1), Action::Move(0, -1), Action::Move(1, -1), Action::Move(-1, 0), Action::Move(1, 0), Action::Move(-1, 1), Action::Move(0, 1), Action::Move(1, 1), Action::Wait, Action::UseMedpatch, ]; for _ in 0..turns { 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::>() { 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; } } #[test] fn random_play_keeps_the_rules() { for seed in 0..40 { let mut crawl = Crawl::new(seed); wander(&mut crawl, &mut seed::rng(seed), 600); 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)); } } } #[test] fn same_seed_same_moves_same_story() { let play = || { let mut crawl = Crawl::new(7); wander(&mut crawl, &mut seed::rng(70), 300); crawl.log }; assert_eq!(play(), play()); } #[test] fn the_airlock_takes_you_home() { let mut crawl = Crawl::new(3); let airlock = crawl.player.neighbours().find(|&n| crawl.map.tile(n) == Tile::Airlock); let airlock = airlock.expect("the start is beside the airlock"); let (dx, dy) = (airlock.x - crawl.player.x, airlock.y - crawl.player.y); crawl.act(Action::Move(dx, dy)); assert_eq!(crawl.outcome, Some(Outcome::Escaped)); } #[test] fn fighting_kills_things() { 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(); 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)); if crawl.monsters.is_empty() { break; } } assert!(crawl.monsters.is_empty(), "a rust-mite survived 50 swings"); assert!(crawl.log.iter().any(|m| m.contains("slag"))); } #[test] fn walking_into_walls_takes_no_time() { let mut crawl = Crawl::new(11); let wall = crawl .player .neighbours() .find(|&n| matches!(crawl.map.tile(n), Tile::Wall | Tile::Hull)); let wall = wall.unwrap(); 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 = 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"); } }