//! 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. use rand::RngExt; use super::fov::{self, Fov}; use super::ship; use super::{Map, Pos, Tile}; use crate::names::Artifact; 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; const LOG_LENGTH: usize = 50; pub struct MonsterKind { pub name: &'static str, pub glyph: char, pub rgb: (u8, u8, u8), pub hp: i32, pub damage: (i32, i32), /// 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, } #[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 }, ]; 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, } pub enum Item { Artifact(Artifact), Salvage { name: &'static str, value: u32 }, Medpatch, } pub struct Loot { pub pos: Pos, pub item: Item, } #[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, } pub struct Crawl { /// "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. It's only yours once you're back through the airlock. pub artifacts: Vec, pub scrip: u32, pub medpatches: u32, 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, pub turns: u32, pub outcome: Option, /// 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, } 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 { 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, artifacts: Vec::new(), scrip: 0, medpatches: 0, seen: vec![false; cells], log: Vec::new(), turns: 0, outcome: None, rng: seed::rng(seed::derive(seed, "dice", 0)), }; crawl.say(format!("You board {}, a derelict {}.", crawl.name, crawl.description)); crawl.look(); 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) } pub fn loot_at(&self, p: Pos) -> Option<&Loot> { self.loot.iter().find(|l| l.pos == p) } /// 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) } /// 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; } let took_time = match action { Action::Wait => true, Action::Move(dx, dy) => self.step(dx, dy), Action::UseMedpatch => self.use_medpatch(), }; if !took_time || self.outcome.is_some() { return; } self.turns += 1; self.monsters_act(); self.look(); } fn say(&mut self, message: String) { self.log.push(message); 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.")); } } 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); 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(); 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()); } } } } fn use_medpatch(&mut self) -> bool { if self.medpatches == 0 { self.say("You have no medpatches.".into()); return false; } if self.hp == self.max_hp { self.say("You're not hurt.".into()); return false; } self.medpatches -= 1; let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp); self.hp += healed; self.say(format!("The medpatch stings, then numbs (+{healed}).")); true } fn attack(&mut self, i: usize) { let name = self.monsters[i].kind.name; self.monsters[i].awake = true; if !self.rng.random_bool(PLAYER_HIT) { self.say(format!("Your torch misses the {name}.")); 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.")); } else { self.say(format!("You burn the {name} ({damage}).")); } } /// 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. 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 { continue; } if monster.pos.steps_to(self.player) == 1 { self.monster_attacks(i); 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; } } } } fn monster_attacks(&mut self, i: usize) { let kind = self.monsters[i].kind; if !self.rng.random_bool(MONSTER_HIT) { self.say(format!("The {} misses you.", kind.name)); return; } let damage = self.rng.random_range(kind.damage.0..=kind.damage.1); 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 }); self.say(format!("You die aboard {}.", self.name)); } } } #[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 { crawl.act(ACTIONS[rng.random_range(..ACTIONS.len())]); } } #[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!(crawl.map.tile(m.pos).walkable(), "seed {seed}: {} in a wall", m.kind.name); assert_ne!(m.pos, crawl.player); } 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(); crawl.monsters = vec![Monster { kind: &MONSTERS[0], pos, hp: 3, awake: true }]; 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); } }