From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/scenes/derelict.rs | 1117 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 1005 insertions(+), 112 deletions(-) (limited to 'src/scenes/derelict.rs') diff --git a/src/scenes/derelict.rs b/src/scenes/derelict.rs index fc18f50..5b705a4 100644 --- a/src/scenes/derelict.rs +++ b/src/scenes/derelict.rs @@ -1,5 +1,8 @@ //! Aboard a derelict: the map, your status, and the log. The rules live in -//! `dungeon::crawl`; this turns keys into actions and draws the result. +//! `dungeon::crawl`; this turns keys into actions and draws the result, +//! including the line of fire while you aim, and shots in flight. `i` opens +//! the inventory (`scenes::inventory`), with what's on the floor beside it, +//! and `c` your character sheet, with what you carry here. use ratatui::Frame; use ratatui::buffer::Buffer; @@ -9,14 +12,28 @@ use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; +use super::character::Sheet; +use super::inventory::{self, Beside, Command, Inventory, Reply, View}; +use super::keys::{self, direction}; +use super::pause::{Choice, Pause}; use super::{Scene, Transition}; use crate::app::Ctx; -use crate::dungeon::crawl::{Action, Crawl, Item, Outcome, SIGHT_RADIUS}; +use crate::dungeon::arms::{Dmg, Weapon}; +use crate::dungeon::crawl::{Action, Affinity, Crawl, Monster, Outcome, SIGHT_RADIUS, Trace}; use crate::dungeon::{Pos, Tile}; +use crate::items::{self, Gear, Item, Stack}; use crate::seed; const PANEL_WIDTH: u16 = 34; const LOG_HEIGHT: u16 = 7; +/// How fast shots cross the screen: cells for every two ticks. +const SHOT_SPEED: u64 = 3; +/// How long a shot's impact shows, in cells' worth of flight. +const IMPACT: u64 = 6; +/// Most monsters listed as in view. +const IN_VIEW: usize = 6; +/// The most steps a run takes. +const RUN_LIMIT: usize = 80; type Rgb = (u8, u8, u8); /// Cells you've seen but can't see now. @@ -24,18 +41,113 @@ const REMEMBERED: Rgb = (64, 68, 84); /// The hull's outline, seen from outside as you docked. const SILHOUETTE: Rgb = (40, 44, 60); const STAR: Rgb = (72, 72, 92); +const LINE_OF_FIRE: Color = Color::Rgb(46, 40, 20); +const RETICLE: Color = Color::Rgb(160, 128, 40); + +#[derive(Clone, Copy)] +enum Mode { + Explore, + /// Picking where to fire. + Aim(Pos), + /// Asking whether to leave by the emergency hatch. + Abandon, +} pub struct Derelict { crawl: Crawl, - /// Asking whether to leave by the emergency hatch. - abandoning: bool, + mode: Mode, + /// Open over the map, when you're looking through what you carry. + inventory: Option, + /// What you came aboard with, to tell what you found. + brought: Gear, /// Once the crawl is over: a title and lines for the closing summary. ending: Option<(&'static str, Vec)>, + /// The tick this turn's shots set off, for drawing them in flight. + volley_at: u64, + /// The menu `esc` opens. + pause: Option, } impl Derelict { pub fn new(seed: u64) -> Self { - Derelict { crawl: Crawl::new(seed), abandoning: false, ending: None } + Derelict { + crawl: Crawl::new(seed), + mode: Mode::Explore, + inventory: None, + brought: Gear::default(), + ending: None, + volley_at: 0, + pause: None, + } + } + + /// Back aboard the wreck a save left you in, just as you left it: what + /// you carried, what you'd seen, what's still hunting you. + pub fn resumed(ctx: &Ctx) -> Option { + let mut crawl = Crawl::resume(ctx.crawl.as_ref()?)?; + crawl.known.clone_from(&ctx.bestiary); + crawl.say(format!("You're back aboard {}, where you left off.", crawl.name)); + let mut brought = ctx.hold.gear.clone(); + brought.reload_all(); + Some(Derelict { + crawl, + mode: Mode::Explore, + inventory: None, + brought, + ending: None, + volley_at: 0, + pause: None, + }) + } + + /// Boards a derelict with everything you carry. Whatever's left of it + /// comes back with you if you get out; your own torch and rivet gun come + /// back whatever happens. What's in your ship's lockers stays safe + /// aboard. What you know about what lives aboard wrecks, you know here + /// too. + /// + /// The hold isn't touched until the crawl ends. The crawl itself is + /// kept in `ctx.crawl` after every move, so a game saved halfway + /// through (or quit) comes back aboard the wreck. + pub fn board(seed: u64, ctx: &mut Ctx) -> Self { + let mut derelict = Derelict::new(seed); + ctx.record.boarded += 1; + let c = &mut derelict.crawl; + c.gear = ctx.hold.gear.clone(); + c.gear.reload_all(); + c.known.clone_from(&ctx.bestiary); + derelict.brought = c.gear.clone(); + let mut brought: Vec = + everything(&c.gear).iter().filter(|s| !s.item.is_kit()).map(Stack::name).collect(); + if !brought.is_empty() { + if brought.len() > 4 { + brought.truncate(3); + brought.push("more".into()); + } + let brought = listed(&brought); + c.say(format!("You've brought {brought}.")); + } + ctx.crawl = Some(derelict.crawl.saved()); + derelict + } + + /// Takes a turn (or doesn't), and keeps up with what came of it. + fn act(&mut self, action: Action, ctx: &mut Ctx) { + self.crawl.act(action); + if !self.crawl.volley.is_empty() { + self.volley_at = ctx.ticks; + } + ctx.bestiary.extend(self.crawl.known.iter().copied()); + for kind in self.crawl.slain.drain(..) { + *ctx.record.slain.entry(kind.to_string()).or_default() += 1; + } + ctx.crawl = self.crawl.outcome.is_none().then(|| self.crawl.saved()); + if let Some(outcome) = self.crawl.outcome + && self.ending.is_none() + { + self.mode = Mode::Explore; + self.finish(outcome, ctx); + } } /// Called once, when the crawl ends: banks what you carried if you got @@ -44,47 +156,190 @@ impl Derelict { let c = &self.crawl; let lines = match outcome { Outcome::Escaped => { - ctx.hold.artifacts.extend(c.artifacts.iter().cloned()); - ctx.hold.scrip += c.scrip; - let mut lines = vec![ - format!("You cast off from {}.", c.name), - String::new(), - format!( - "Recovered {} and {} ¤ of salvage.", - count(c.artifacts.len(), "artifact"), - c.scrip - ), - ]; - lines.extend(c.artifacts.iter().map(|a| format!(" * {}", a.unidentified))); + ctx.record.escaped += 1; + ctx.hold.gear = c.gear.clone(); + let found = gained(&self.brought, &c.gear); + let mut lines = vec![format!("You cast off from {}.", c.name), String::new()]; + if found.is_empty() { + lines.push("You come away with nothing new.".into()); + } else { + lines.push("You come away with:".into()); + lines.extend(found.iter().map(|s| { + let (glyph, _) = inventory::look(&s.item); + format!(" {glyph} {}", s.name()) + })); + } + let worth: u32 = found + .iter() + .map(|s| match &s.item { + Item::Salvage(v) => v.value * s.count, + _ => 0, + }) + .sum(); + if worth > 0 { + lines.push(format!("The salvage is worth about {worth} ¤ to a broker.")); + } lines.push(String::new()); lines.push(format!( - "Your hold now carries {} and {} ¤.", - count(ctx.hold.artifacts.len(), "artifact"), - ctx.hold.scrip + "You carry {} of {} kg. Stow what you don't need in the lockers [i].", + items::kg(c.gear.carried()), + items::kg(c.gear.capacity) )); lines } - Outcome::Died { killer } => vec![ - format!("The {killer} brings you down aboard {}.", c.name), - "Your crew drags you back to your ship, but".into(), - "everything you carried stays with the wreck.".into(), - ], + Outcome::Died { killer } => { + ctx.record.downed += 1; + ctx.hold.lose_carried(&c.gear); + vec![ + format!("The {killer} brings you down aboard {}.", c.name), + format!("Your crew drags you back to {}, but", ctx.character.ship_name()), + "everything you carried stays with the wreck.".into(), + ] + } }; let title = match outcome { Outcome::Escaped => " SAFE ABOARD YOUR SHIP ", Outcome::Died { .. } => " PULLED BACK FROM THE EDGE ", }; self.ending = Some((title, lines)); + // You may well be holding the key that walked you into the + // airlock: its repeats mustn't wave the summary away unread. + ctx.settle(); + } + + /// Shift and a direction: steps that way until there's something to + /// stop for: something in sight, a wound, something said or underfoot, + /// a doorway, another room, a closed door or the airlock ahead. With + /// something in sight already, it's just a step. + fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) { + for step in 0..RUN_LIMIT { + let c = &self.crawl; + // Opening a door and leaving are steps you take, not ones a + // run carries you into. + let ahead = c.map.tile(c.player.offset(dx, dy)); + if step > 0 && matches!(ahead, Tile::Airlock | Tile::Door { open: false }) { + break; + } + let (from, hp, said) = (c.player, c.hp, c.said); + let room = c.map.room_index(c.player); + self.act(Action::Move(dx, dy), ctx); + let c = &self.crawl; + let stop = c.outcome.is_some() + || c.player == from + || c.hp < hp + || c.said != said + || c.monsters.iter().any(|m| c.fov.contains(m.pos)) + || !c.here().is_empty() + || matches!(c.map.tile(c.player), Tile::Door { .. }) + // Out of a doorway, the first step is into the room. + || (step > 0 && c.map.room_index(c.player) != room); + if stop { + break; + } + } } - fn render_map(&self, buf: &mut Buffer, area: Rect) { + fn explore(&mut self, code: KeyCode) -> Option { + if let Some((dx, dy)) = direction(code) { + return Some(Action::Move(dx, dy)); + } + match code { + KeyCode::Char('.' | '5') => Some(Action::Wait), + KeyCode::Char('a') => Some(Action::UseMedpatch), + KeyCode::Char('r') => Some(Action::Reload), + KeyCode::Char('g' | ',') if self.crawl.here().len() > 1 => { + self.inventory = Some(Inventory::beside()); + None + } + KeyCode::Char('g' | ',') => Some(Action::PickUp), + KeyCode::Char('f') if self.crawl.gear.gun.loaded == 0 => Some(Action::Reload), + KeyCode::Char('f') => { + let at = self.targets().first().copied().unwrap_or(self.crawl.player); + self.mode = Mode::Aim(at); + None + } + KeyCode::Char('i') => { + self.inventory = Some(Inventory::new()); + None + } + KeyCode::Esc => { + let note = format!( + "Aboard {}. Quit, and you'll come back aboard just as you left it.", + self.crawl.name + ); + self.pause = Some(Pause::new(Some("Abandon ship"), Some(note))); + None + } + _ => None, + } + } + + fn aim(&mut self, code: KeyCode, at: Pos) -> Option { + if let Some((dx, dy)) = direction(code) { + let to = at.offset(dx, dy); + if self.crawl.map.contains(to) { + self.mode = Mode::Aim(to); + } + return None; + } + match code { + KeyCode::Tab | KeyCode::Char('+' | '=') => self.mode = Mode::Aim(self.cycle(at, 1)), + KeyCode::BackTab | KeyCode::Char('-') => self.mode = Mode::Aim(self.cycle(at, -1)), + KeyCode::Enter | KeyCode::Char('f' | 't') if at != self.crawl.player => { + self.mode = Mode::Explore; + return Some(Action::Fire(at)); + } + KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Explore, + _ => {} + } + None + } + + /// Where the things you can see are, nearest first. + fn targets(&self) -> Vec { + let c = &self.crawl; + let mut targets: Vec = + c.monsters.iter().map(|m| m.pos).filter(|&p| c.fov.contains(p)).collect(); + targets.sort_by_key(|&p| (distance2(c.player, p), p.y, p.x)); + targets + } + + /// The next target after (or before) the one at `at`. + fn cycle(&self, at: Pos, by: isize) -> Pos { + let targets = self.targets(); + match targets.iter().position(|&p| p == at) { + _ if targets.is_empty() => at, + Some(i) => targets[(i as isize + by).rem_euclid(targets.len() as isize) as usize], + None => targets[0], + } + } + + fn render_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) { let c = &self.crawl; let left = camera(c.player.x, c.map.width, i32::from(area.width)); let top = camera(c.player.y, c.map.height, i32::from(area.height)); + let flying = self.in_flight(ticks); + let (aim, line) = match self.mode { + Mode::Aim(at) => (Some(at), c.line_of_fire(at)), + _ => (None, Vec::new()), + }; for sy in 0..area.height { for sx in 0..area.width { let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); - let (glyph, style) = self.look(p); + let (mut glyph, mut style) = self.look(p); + if line.contains(&p) { + style = style.bg(LINE_OF_FIRE); + } + if aim == Some(p) { + style = style.bg(RETICLE); + if p != c.player && c.monster_at(p).is_none() { + (glyph, style) = ('×', style.fg(Color::Black)); + } + } + if let Some(&(_, g, colour)) = flying.iter().find(|(q, ..)| *q == p) { + glyph = g; + style = style.fg(rgb(colour, 1.0)).bold(); + } if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) { cell.set_char(glyph).set_style(style); } @@ -92,6 +347,29 @@ impl Derelict { } } + /// What of this turn's shots is on screen `ticks` in: each round's head + /// and a fading tail while it flies, then a flash where it stops. + fn in_flight(&self, ticks: u64) -> Vec<(Pos, char, Rgb)> { + let c = &self.crawl; + let flown = ticks.saturating_sub(self.volley_at) * SHOT_SPEED / 2; + let mut out = Vec::new(); + for trace in &c.volley { + let Some(flown) = flown.checked_sub(u64::from(trace.delay)) else { continue }; + let (len, colour) = (trace.cells.len() as u64, trace.dmg.rgb()); + let Some(&end) = trace.cells.last() else { continue }; + if flown < len { + out.push((trace.cells[flown as usize], shot_glyph(trace), colour)); + if flown > 0 { + out.push((trace.cells[flown as usize - 1], '·', dim(colour, 0.6))); + } + } else if flown < len + IMPACT { + out.push((end, if trace.hit { '*' } else { '∙' }, colour)); + } + } + out.retain(|(p, ..)| c.fov.contains(*p)); + out + } + /// How the cell at `p` looks from where you stand: lit by your lamp if /// you can see it, from memory if you've seen it, and otherwise only the /// hull's outline against the stars. @@ -105,10 +383,10 @@ impl Derelict { if let Some(m) = c.monster_at(p) { return (m.kind.glyph, Style::new().fg(rgb(m.kind.rgb, 1.0)).bold()); } - let dist = f64::from((p.x - c.player.x).pow(2) + (p.y - c.player.y).pow(2)).sqrt(); + let dist = f64::from(distance2(p, c.player)).sqrt(); let light = 1.0 - 0.6 * (dist / f64::from(SIGHT_RADIUS)).min(1.0); if let Some(loot) = c.loot_at(p) { - let (glyph, colour) = item_look(&loot.item); + let (glyph, colour) = inventory::look(&loot.stack.item); return (glyph, Style::new().fg(rgb(colour, light.max(0.8))).bold()); } let (glyph, colour) = tile_look(tile); @@ -116,7 +394,7 @@ impl Derelict { } if c.seen(p) { if let Some(loot) = c.loot_at(p) { - let (glyph, colour) = item_look(&loot.item); + let (glyph, colour) = inventory::look(&loot.stack.item); return (glyph, Style::new().fg(rgb(colour, 0.45))); } return (tile_look(tile).0, Style::new().fg(rgb(REMEMBERED, 1.0))); @@ -138,45 +416,213 @@ impl Derelict { .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); - let [info, help] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(inner); + let help = self.help(inner.width); + let [info, keys] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(help.len() as u16)]) + .areas(inner); - let label = |text: &'static str| Span::from(text).dark_gray(); let mut lines = vec![ Line::from(c.name.as_str()).bold().fg(Color::Yellow), Line::from(c.description.as_str()).italic().dark_gray(), Line::default(), Line::from(vec![label("health "), health_bar(c.hp, c.max_hp, 12)]), - Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.medpatches))]), + ]; + if c.bleeding > 0 { + lines.push(Line::from(vec![ + label(" "), + format!("bleeding {}", c.bleeding).red(), + ])); + } + if c.burning > 0 { + lines.push(Line::from(vec![ + label(" "), + Span::from(format!("alight {}", c.burning)).fg(rgb(Dmg::Burn.rgb(), 1.0)), + ])); + } + lines.extend([ + Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.gear.medpatches()))]), Line::from(vec![label("here "), Span::from(c.location())]), Line::from(vec![label("turn "), Span::from(c.turns.to_string())]), Line::default(), - Line::from(vec![label("salvage "), Span::from(format!("{} ¤", c.scrip))]), - Line::from(vec![label("artifacts "), Span::from(c.artifacts.len().to_string())]), - ]; - for artifact in &c.artifacts { - lines.push(Line::from(format!("* {}", artifact.unidentified)).fg(Color::Yellow)); + ]); + + let (blade, gun) = (&c.gear.melee, &c.gear.gun); + let blade_stats = format!("{} · {}%", blade.damage_text(), percent(blade.accuracy)); + lines.extend([ + Line::from(vec![label("melee "), title(blade, 20)]), + Line::from(vec![label(" "), blade_stats.dark_gray()]), + Line::from(vec![label("ranged "), title(gun, 20)]), + Line::from(vec![ + label(" "), + magazine(gun), + format!(" {}", gun.damage_text()).dark_gray(), + ]), + Line::from(vec![ + label("carrying "), + Span::from(format!( + "{} / {} kg", + items::kg(c.gear.carried()), + items::kg(c.gear.capacity) + )), + ]), + Line::default(), + ]); + + match self.mode { + Mode::Aim(at) => self.target_lines(at, &mut lines), + _ => self.in_view_lines(&mut lines), + } + + let found = gained(&self.brought, &c.gear); + if !found.is_empty() { + lines.push(Line::from("found").dark_gray()); + } + for stack in &found { + let (glyph, colour) = inventory::look(&stack.item); + lines.push(Line::from(vec![ + Span::from(format!("{glyph} ")).fg(rgb(colour, 1.0)), + Span::from(stack.name()), + ])); } frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + frame.render_widget(Paragraph::new(help), keys); + } - // Pairs of (key, what it does) on one line. - let keys = |pairs: &[(&'static str, &'static str)]| { - Line::from( - pairs - .iter() - .flat_map(|&(k, what)| [Span::from(k).bold(), Span::from(what).dark_gray()]) - .collect::>(), - ) + /// The monsters you can see, nearest first, with how hurt they are. + fn in_view_lines<'a>(&'a self, lines: &mut Vec>) { + let c = &self.crawl; + let mut seen: Vec<&Monster> = c.monsters.iter().filter(|m| c.fov.contains(m.pos)).collect(); + if seen.is_empty() { + return; + } + seen.sort_by_key(|m| distance2(c.player, m.pos)); + lines.push(Line::from("in view").dark_gray()); + for m in seen.iter().take(IN_VIEW) { + let mut spans = vec![ + Span::styled( + m.kind.glyph.to_string(), + Style::new().fg(rgb(m.kind.rgb, 1.0)).bold(), + ), + Span::from(format!(" {:<18}", m.kind.name)), + mini_bar(m.hp, m.kind.hp, 6), + ]; + if m.bleeding > 0 { + spans.push("•".red()); + } + if m.burning > 0 { + spans.push(Span::from("*").fg(rgb(Dmg::Burn.rgb(), 1.0))); + } + if m.stunned > 0 { + spans.push("z".yellow()); + } + lines.push(Line::from(spans)); + } + if seen.len() > IN_VIEW { + lines.push(Line::from(format!(" and {} more", seen.len() - IN_VIEW)).dark_gray()); + } + lines.push(Line::default()); + } + + /// What's under the reticle: how hurt it is, the odds, and what you + /// know of how it takes each kind of damage. + fn target_lines<'a>(&'a self, at: Pos, lines: &mut Vec>) { + let c = &self.crawl; + if at == c.player { + lines.push(Line::from("Nothing in sight to shoot.").dark_gray()); + lines.push(Line::from("Aim with the move keys.").dark_gray()); + lines.push(Line::default()); + return; + } + let away = f64::from(distance2(c.player, at)).sqrt().round(); + let away = + if away == 1.0 { "1 cell away".to_string() } else { format!("{away} cells away") }; + let Some(m) = c.monster_at(at).filter(|m| c.fov.contains(m.pos)) else { + lines.push(Line::from(vec![label("target "), Span::from("nothing there")])); + let range = if c.in_range(at) { "" } else { " · out of range" }; + lines.push(Line::from(format!("{away}{range}")).dark_gray()); + lines.push(Line::default()); + return; }; - frame.render_widget( - Paragraph::new(vec![ - keys(&[("arrows hjkl yubn", " move")]), - keys(&[(".", " wait "), ("m", " medpatch")]), - keys(&[("walk into Θ", " leave with loot")]), - keys(&[("esc", " abandon ship, loot and all")]), - ]), - help, - ); + let kind = m.kind; + lines.push(Line::from(vec![ + label("target "), + Span::styled(kind.name, Style::new().fg(rgb(kind.rgb, 1.0)).bold()), + ])); + let mut state = vec![mini_bar(m.hp, kind.hp, 12)]; + if m.bleeding > 0 { + state.push(format!(" bleeding {}", m.bleeding).red()); + } + if m.burning > 0 { + state.push(Span::from(" burning").fg(rgb(Dmg::Burn.rgb(), 1.0))); + } + if m.stunned > 0 { + state.push(" stunned".yellow()); + } + lines.push(Line::from(state)); + let odds = match c.odds(at) { + _ if !c.in_range(at) => "out of range".to_string(), + Some(p) if c.gear.gun.gun.is_some_and(|g| g.pellets > 1) => { + format!("{}% a pellet", percent(p)) + } + Some(p) => format!("{}% to hit", percent(p)), + None => String::new(), + }; + lines.push(Line::from(format!("{away} · {odds}")).dark_gray()); + + let mut known: [(Affinity, Vec<&str>); 4] = [ + (Affinity::Weak, Vec::new()), + (Affinity::Resists, Vec::new()), + (Affinity::Immune, Vec::new()), + (Affinity::Normal, Vec::new()), + ]; + let mut untried = Vec::new(); + for dmg in Dmg::ALL { + match c.knows(kind, dmg) { + Some(a) => known.iter_mut().find(|(k, _)| *k == a).unwrap().1.push(dmg.name()), + None => untried.push(dmg.name()), + } + } + for (affinity, dmgs) in known { + if dmgs.is_empty() { + continue; + } + let (word, colour) = match affinity { + Affinity::Weak => ("weak to ", Color::Green), + Affinity::Resists => ("resists ", Color::Yellow), + Affinity::Immune => ("immune to ", Color::Red), + Affinity::Normal => ("no matter: ", Color::Gray), + }; + lines.push(Line::from(vec![Span::from(word).fg(colour), Span::from(dmgs.join(", "))])); + } + if !untried.is_empty() { + lines.push(Line::from(format!("untried: {}", untried.join(", "))).dark_gray()); + } + lines.push(Line::default()); + } + + fn help(&self, width: u16) -> Vec> { + let hints: &[(&str, &str)] = match self.mode { + Mode::Aim(_) => &[ + ("move keys", "aim"), + ("tab", "next target"), + ("f enter", "fire"), + ("esc", "lower it"), + ], + _ => &[ + ("arrows hjkl yubn", "move, attack"), + ("shift", "run"), + ("f", "fire"), + ("r", "reload"), + (".", "wait"), + ("a", "medpatch"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("into Θ", "leave with loot"), + ("esc", "menu: quit or abandon ship"), + ], + }; + keys::packed(hints, width) } fn render_log(&self, frame: &mut Frame, area: Rect) { @@ -201,75 +647,214 @@ impl Derelict { } } +/// Everything on you, as stacks: worn, ready and packed. +fn everything(gear: &Gear) -> Vec { + let worn = gear.worn.values().map(|a| Stack::one(Item::Armor(a.clone()))); + let ready = [&gear.melee, &gear.gun].into_iter().map(|w| Stack::one(Item::Weapon(w.clone()))); + worn.chain(ready).chain(gear.pack.iter().cloned()).collect() +} + +/// What you have now that you didn't come aboard with. +fn gained(before: &Gear, after: &Gear) -> Vec { + // A gun you've fired is still the gun you brought. + let unloaded = |mut s: Stack| { + if let Item::Weapon(w) = &mut s.item { + w.loaded = 0; + } + s + }; + let mut had: Vec = everything(before).into_iter().map(unloaded).collect(); + let mut found = Vec::new(); + for stack in everything(after).into_iter().map(unloaded) { + let mut count = stack.count; + if let Some(old) = had.iter_mut().find(|s| s.item == stack.item) { + let same = old.count.min(count); + old.count -= same; + count -= same; + } + if count > 0 { + items::stow(&mut found, Stack { count, ..stack }); + } + } + found +} + impl Scene for Derelict { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { if self.ending.is_some() { return Transition::Pop; } - if self.abandoning { - self.abandoning = false; - return match key.code { - KeyCode::Char('y' | 'Y') => Transition::Pop, - _ => Transition::None, + if let Some(pause) = &mut self.pause { + return match pause.key(key) { + None => Transition::None, + Some(choice) => { + self.pause = None; + match choice { + Choice::Quit => Transition::Title, + Choice::Here => { + self.mode = Mode::Abandon; + Transition::None + } + Choice::Resume => Transition::None, + } + } }; } - // Vi keys, arrows, and the number pad with or without num lock. - let action = match key.code { - KeyCode::Left | KeyCode::Char('h' | '4') => Action::Move(-1, 0), - KeyCode::Right | KeyCode::Char('l' | '6') => Action::Move(1, 0), - KeyCode::Up | KeyCode::Char('k' | '8') => Action::Move(0, -1), - KeyCode::Down | KeyCode::Char('j' | '2') => Action::Move(0, 1), - KeyCode::Home | KeyCode::Char('y' | '7') => Action::Move(-1, -1), - KeyCode::PageUp | KeyCode::Char('u' | '9') => Action::Move(1, -1), - KeyCode::End | KeyCode::Char('b' | '1') => Action::Move(-1, 1), - KeyCode::PageDown | KeyCode::Char('n' | '3') => Action::Move(1, 1), - KeyCode::Char('.' | '5') => Action::Wait, - KeyCode::Char('m') => Action::UseMedpatch, - KeyCode::Esc => { - self.abandoning = true; - return Transition::None; + if let Some(inv) = &mut self.inventory { + let here: Vec = + self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); + let view = View { + gear: &self.crawl.gear, + beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), + known: &self.crawl.known, + hurt: true, + shop: None, + sort: ctx.inventory_sort, + }; + let action = match inv.key(key, &view) { + Reply::Stay => return Transition::None, + Reply::Close => { + self.inventory = None; + return Transition::None; + } + Reply::Sort(sort) => { + ctx.inventory_sort = sort; + return Transition::None; + } + Reply::Do(command) => match command { + Command::Ready(i) => Action::Ready(i), + Command::Wear(i) => Action::Wear(i), + Command::TakeOff(slot) => Action::TakeOff(slot), + Command::Eat(i) => Action::Eat(i), + Command::UseMedpatch => Action::UseMedpatch, + Command::Drop(spot, n) | Command::Stow(spot, n) => Action::Drop(spot, n), + Command::Fetch(i, n) => Action::Take(i, n), + // There's no one to trade with aboard a wreck. + Command::Buy(_) + | Command::Sell(_) + | Command::SellAboard(_) + | Command::Mend(_) + | Command::MendAboard(_) + | Command::MendAll => { + return Transition::None; + } + }, + }; + let turns = self.crawl.turns; + self.act(action, ctx); + // Anything that took a turn closes it, so you see what happened. + match &mut self.inventory { + Some(_) if self.crawl.turns != turns || self.ending.is_some() => { + self.inventory = None; + } + Some(inv) => inv.note = self.crawl.log.last().cloned(), + None => {} + } + return Transition::None; + } + let action = match self.mode { + Mode::Abandon => { + self.mode = Mode::Explore; + return match key.code { + KeyCode::Char('y' | 'Y') => { + ctx.record.abandoned += 1; + ctx.hold.lose_carried(&self.crawl.gear); + ctx.crawl = None; + Transition::Pop + } + _ => Transition::None, + }; } - _ => return Transition::None, + Mode::Explore => match keys::movement(key) { + Some((dx, dy, true)) => { + self.run(dx, dy, ctx); + return Transition::None; + } + _ if matches!(key.code, KeyCode::Char('c' | 'C')) => { + let gear = self.crawl.gear.clone(); + return Transition::Push(Box::new(Sheet::aboard(&self.crawl.name, gear))); + } + _ => self.explore(key.code), + }, + Mode::Aim(at) => self.aim(key.code, at), }; - self.crawl.act(action); - if let Some(outcome) = self.crawl.outcome { - self.finish(outcome, ctx); + if let Some(action) = action { + self.act(action, ctx); } Transition::None } - fn render(&self, frame: &mut Frame, _ctx: &Ctx) { + fn render(&self, frame: &mut Frame, ctx: &Ctx) { let [main, panel] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) .areas(frame.area()); let [map, log] = Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); - self.render_map(frame.buffer_mut(), map); + self.render_map(frame.buffer_mut(), map, ctx.ticks); self.render_log(frame, log); self.render_panel(frame, panel); - if self.abandoning { - popup( + if let Some((title, lines)) = &self.ending { + let mut lines: Vec = lines.iter().map(|l| Line::from(l.as_str())).collect(); + lines.extend([Line::default(), keys::line(&[("any key", "carry on")])]); + popup(frame, title, lines); + return; + } + match self.mode { + Mode::Abandon => popup( frame, " ABANDON SHIP? ", vec![ - format!("Leave {} by the emergency hatch?", self.crawl.name), - "Whatever you're carrying stays behind.".into(), - String::new(), - "[y] abandon [any other key] keep exploring".into(), + Line::from(format!("Leave {} by the emergency hatch?", self.crawl.name)), + Line::from("Whatever you're carrying stays behind."), + Line::default(), + keys::line(&[("y", "abandon"), ("any other key", "keep exploring")]), ], - ); - } else if let Some((title, lines)) = &self.ending { - let mut lines = lines.clone(); - lines.extend([String::new(), "[any key]".into()]); - popup(frame, title, lines); + ), + Mode::Explore | Mode::Aim(_) => {} + } + if let Some(inv) = &self.inventory { + let here: Vec = + self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); + let view = View { + gear: &self.crawl.gear, + beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), + known: &self.crawl.known, + hurt: true, + shop: None, + sort: ctx.inventory_sort, + }; + inv.render(frame, frame.area(), &view); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); } } } +/// A round in flight, pointing the way it's going. Cells are about twice as +/// tall as they're wide, so the slope on screen is double the slope in cells. +fn shot_glyph(trace: &Trace) -> char { + match trace.dmg { + Dmg::Burn => return '•', + Dmg::Shock => return '~', + Dmg::Hymn => return '♪', + _ => {} + } + let (Some(first), Some(last)) = (trace.cells.first(), trace.cells.last()) else { return '*' }; + let (dx, dy) = (f64::from(last.x - first.x), f64::from(last.y - first.y) * 2.0); + let angle = dy.atan2(dx).to_degrees().rem_euclid(180.0); + match angle { + a if !(22.5..157.5).contains(&a) => '-', + a if a < 67.5 => '\\', + a if a < 112.5 => '|', + _ => '/', + } +} + /// The left or top edge of the view: keeps `focus` centred, but doesn't /// scroll past the map's edges, and centres a map smaller than the view. -fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 { +pub(crate) fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 { if map_size <= view_size { -(view_size - map_size) / 2 } else { @@ -290,12 +875,34 @@ pub(crate) fn tile_look(tile: Tile) -> (char, Rgb) { } } -pub(crate) fn item_look(item: &Item) -> (char, Rgb) { - match item { - Item::Artifact(_) => ('*', (255, 214, 92)), - Item::Salvage { .. } => ('%', (150, 200, 222)), - Item::Medpatch => ('!', (240, 96, 96)), +/// A weapon's name in the colour of its damage, cut to `width`. Relics +/// stand out. +fn title(w: &Weapon, width: usize) -> Span<'static> { + let name: String = w.name().chars().take(width).collect(); + let style = Style::new().fg(rgb(w.dmg.rgb(), 1.0)); + Span::styled(name, if w.named { style.bold() } else { style }) +} + +/// Rounds left, as pips. +fn magazine(gun: &Weapon) -> Span<'static> { + let Some(g) = gun.gun else { return Span::from("") }; + if gun.loaded == 0 { + return "empty (r)".red(); } + let pips = "▮".repeat(gun.loaded as usize) + &"▯".repeat((g.magazine - gun.loaded) as usize); + Span::from(pips).white() +} + +fn label(text: &'static str) -> Span<'static> { + Span::from(text).dark_gray() +} + +fn percent(p: f64) -> i32 { + (p * 100.0).round() as i32 +} + +fn distance2(a: Pos, b: Pos) -> i32 { + (a.x - b.x).pow(2) + (a.y - b.y).pow(2) } fn rgb((r, g, b): Rgb, light: f64) -> Color { @@ -303,6 +910,11 @@ fn rgb((r, g, b): Rgb, light: f64) -> Color { Color::Rgb(scale(r), scale(g), scale(b)) } +fn dim((r, g, b): Rgb, k: f64) -> Rgb { + let scale = |c: u8| (f64::from(c) * k).round() as u8; + (scale(r), scale(g), scale(b)) +} + /// A sprinkling of stars that stays put as the view scrolls. fn is_star(p: Pos) -> bool { let cell = (u64::from(p.x as u32) << 32) | u64::from(p.y as u32); @@ -320,14 +932,32 @@ fn health_bar(hp: i32, max: i32, width: i32) -> Span<'static> { Span::styled(format!("{bar} {hp}/{max}"), Style::new().fg(colour)) } -fn count(n: usize, noun: &str) -> String { - if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") } +/// A monster's health, without the numbers: you can see how hurt it is, +/// not count its hit points. +fn mini_bar(hp: i32, max: i32, width: i32) -> Span<'static> { + let hp = hp.clamp(0, max); + let filled = ((hp * width + max - 1) / max).clamp(0, width); + let colour = match hp * 4 / max { + 3.. => Color::Green, + 2 => Color::Yellow, + _ => Color::Red, + }; + let bar = "█".repeat(filled as usize) + &"░".repeat((width - filled) as usize); + Span::styled(bar, Style::new().fg(colour)) } -fn popup(frame: &mut Frame, title: &str, lines: Vec) { +fn listed(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +fn popup(frame: &mut Frame, title: &str, lines: Vec) { let area = frame.area(); - let widest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16; - let width = (widest + 4).min(area.width); + let widest = lines.iter().map(Line::width).max().unwrap_or(0) as u16; + let width = (widest + 4).max(title.chars().count() as u16 + 4).min(area.width); let height = (lines.len() as u16 + 2).min(area.height); let [popup] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); let [popup] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup); @@ -335,7 +965,6 @@ fn popup(frame: &mut Frame, title: &str, lines: Vec) { .border_style(Style::new().fg(Color::Yellow)) .title(Line::from(title.to_string()).bold()) .padding(Padding::horizontal(1)); - let lines: Vec = lines.into_iter().map(Line::from).collect(); frame.render_widget(Clear, popup); frame.render_widget(Paragraph::new(lines).block(block), popup); } @@ -347,7 +976,9 @@ mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; + use crate::dungeon::arms::{self, Source}; use crate::term::KeyboardCaps; + use ratatui::crossterm::event::KeyCode as Key; fn ctx() -> Ctx { Ctx::new(1, KeyboardCaps { key_release: false }) @@ -379,27 +1010,100 @@ mod tests { }) } + fn shifted(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::SHIFT), ctx) + } + + #[test] + fn running_goes_until_something_happens_but_not_out_the_airlock() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + let start = scene.crawl.player; + let toward = key_toward_airlock(&scene); + let KeyCode::Char(c) = toward else { unreachable!() }; + let away = keys::direction(toward).map(|(dx, dy)| (-dx, -dy)).unwrap(); + let away = [ + Key::Left, + Key::Right, + Key::Up, + Key::Down, + Key::Home, + Key::PageUp, + Key::End, + Key::PageDown, + ] + .into_iter() + .find(|&k| keys::direction(k) == Some(away)) + .unwrap(); + shifted(&mut scene, away, &mut ctx); + let ran = scene.crawl.player.steps_to(start); + assert!(ran >= 1, "it gets going"); + assert_eq!(scene.crawl.turns, ran as u32, "a turn a step, straight on"); + // And back: it stops beside the airlock rather than go through. + for _ in 0..ran { + shifted(&mut scene, KeyCode::Char(c.to_ascii_uppercase()), &mut ctx); + if scene.crawl.player == start { + break; + } + } + assert_eq!(scene.crawl.player, start); + assert!(scene.crawl.outcome.is_none(), "still aboard"); + assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); + assert!(scene.crawl.outcome.is_some(), "a step of your own takes you out"); + } + + #[test] + fn a_medpatch_is_a_and_the_sheet_is_c() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(medpatches(1)); + scene.crawl.hp -= 5; + press(&mut scene, KeyCode::Char('a'), &mut ctx); + assert_eq!(scene.crawl.gear.medpatches(), 0); + assert!(matches!(press(&mut scene, KeyCode::Char('c'), &mut ctx), Transition::Push(_))); + } + + fn medpatches(n: u32) -> Stack { + Stack { item: Item::Medpatch, count: n } + } + + fn salvage() -> Item { + Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 12, mass: 2000 }) + } + #[test] fn escaping_banks_what_you_carry() { let mut ctx = ctx(); - let mut scene = Derelict::new(3); - scene.crawl.scrip = 55; + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack { item: salvage(), count: 2 }); let toward = key_toward_airlock(&scene); assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); - assert_eq!(ctx.hold.scrip, 55); - assert!(screen(&scene, &ctx).contains("SAFE ABOARD YOUR SHIP")); + assert_eq!(ctx.hold.gear.pack.iter().filter(|s| s.item == salvage()).count(), 1); + let shown = screen(&scene, &ctx); + assert!(shown.contains("SAFE ABOARD YOUR SHIP")); + assert!(shown.contains("2 coils of wire") && shown.contains("24 ¤"), "{shown}"); assert!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop)); } + /// Esc, then the menu's second choice, then yes. + fn abandon(scene: &mut Derelict, ctx: &mut Ctx) { + press(scene, KeyCode::Esc, ctx); + press(scene, KeyCode::Char('2'), ctx); + press(scene, KeyCode::Char('y'), ctx); + } + #[test] fn abandoning_leaves_it_behind() { let mut ctx = ctx(); - let mut scene = Derelict::new(3); - scene.crawl.scrip = 55; + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack::one(salvage())); press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(screen(&scene, &ctx).contains("PAUSED")); + press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(screen(&scene, &ctx).contains("ABANDON SHIP?")); assert!(matches!(press(&mut scene, KeyCode::Char('y'), &mut ctx), Transition::Pop)); - assert_eq!(ctx.hold.scrip, 0); + assert!(ctx.hold.gear.pack.iter().all(|s| s.item.is_kit())); + assert!(ctx.crawl.is_none(), "nothing to come back to"); } #[test] @@ -407,7 +1111,196 @@ mod tests { let mut ctx = ctx(); let mut scene = Derelict::new(3); press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(press(&mut scene, KeyCode::Char('q'), &mut ctx), Transition::None)); + assert!(!screen(&scene, &ctx).contains("PAUSED")); + press(&mut scene, KeyCode::Esc, &mut ctx); + press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None)); assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?")); } + + #[test] + fn saving_and_quitting_brings_you_back_where_you_were() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear.add(Stack::one(salvage())); + let key = key_toward_airlock(&scene); + // A step away from the airlock and back is a turn or two in. + let away = match key { + KeyCode::Char('h') => KeyCode::Char('l'), + KeyCode::Char('l') => KeyCode::Char('h'), + KeyCode::Char('j') => KeyCode::Char('k'), + _ => KeyCode::Char('j'), + }; + press(&mut scene, away, &mut ctx); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(press(&mut scene, KeyCode::Char('3'), &mut ctx), Transition::Title)); + let saved = ctx.crawl.clone().expect("the wreck is kept"); + assert_eq!(saved, scene.crawl.saved()); + + let back = Derelict::resumed(&ctx).unwrap(); + let (was, is) = (&scene.crawl, &back.crawl); + assert_eq!((is.player, is.hp, is.turns), (was.player, was.hp, was.turns)); + assert_eq!(is.gear, was.gear); + assert_eq!(is.loot, was.loot); + assert_eq!(is.monsters.len(), was.monsters.len()); + assert!(is.log.last().unwrap().contains("where you left off")); + assert_eq!(back.brought, ctx.hold.gear, "what's found is what you didn't bring"); + assert_eq!(ctx.record.boarded, 1, "coming back isn't boarding again"); + let seen = |c: &Crawl| c.map.positions().filter(|&p| c.seen(p)).count(); + assert_eq!(seen(is), seen(was), "what you'd seen you still have"); + assert!(was.map.positions().all(|p| is.seen(p) == was.seen(p))); + } + + #[test] + fn medpatches_go_aboard_and_come_back_if_you_do() { + let mut ctx = ctx(); + ctx.hold.gear.add(medpatches(3)); + ctx.hold.lockers.push(medpatches(4)); + let mut scene = Derelict::board(3, &mut ctx); + assert_eq!((scene.crawl.gear.medpatches(), ctx.hold.medpatches()), (3, 7)); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + assert_eq!(scene.crawl.outcome, Some(Outcome::Escaped)); + assert_eq!(ctx.hold.gear.medpatches(), 3); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + assert_eq!(ctx.hold.gear.medpatches(), 0, "abandon ship and they stay behind"); + assert_eq!(ctx.hold.medpatches(), 4, "but not what's in the lockers"); + } + + #[test] + fn weapons_go_aboard_and_come_back_if_you_do() { + let mut ctx = ctx(); + let mut cleaver = arms::find(&mut seed::rng(2), Source::Crew, false); + cleaver.loaded = 0; + ctx.hold.gear.add(Stack::one(Item::Weapon(cleaver.clone()))); + let found = |g: &Gear| g.weapons().filter(|w| !w.kit).count(); + let mut scene = Derelict::board(3, &mut ctx); + assert_eq!((found(&scene.crawl.gear), found(&ctx.hold.gear)), (1, 1)); + assert!(scene.crawl.gear.weapons().all(|w| w.loaded == w.gun.map_or(0, |g| g.magazine))); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + assert_eq!(found(&ctx.hold.gear), 1); + assert!(screen(&scene, &ctx).contains("nothing new"), "what you brought isn't found"); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + assert_eq!(found(&ctx.hold.gear), 0, "abandon ship and it stays behind"); + assert_eq!(ctx.hold.gear.weapons().count(), 2, "but not your own torch and rivet gun"); + } + + #[test] + fn aiming_and_the_inventory_open_and_close() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + press(&mut scene, KeyCode::Char('f'), &mut ctx); + assert!(matches!(scene.mode, Mode::Aim(_))); + assert!(screen(&scene, &ctx).contains("next target")); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(matches!(scene.mode, Mode::Explore)); + assert_eq!(scene.crawl.turns, 0, "lowering your gun takes no time"); + + press(&mut scene, KeyCode::Char('i'), &mut ctx); + let shown = screen(&scene, &ctx); + assert!(shown.contains("YOU ·") && shown.contains("cutting torch"), "{shown}"); + press(&mut scene, KeyCode::Char('j'), &mut ctx); + assert_eq!(scene.crawl.turns, 0, "looking takes no time"); + press(&mut scene, KeyCode::Esc, &mut ctx); + assert!(scene.inventory.is_none()); + } + + #[test] + fn using_things_from_the_inventory_takes_a_turn_and_closes_it() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + scene.crawl.gear.add(Stack::one(salvage())); + scene.crawl.gear.add(medpatches(1)); + scene.crawl.hp -= 5; + press(&mut scene, KeyCode::Char('i'), &mut ctx); + find(&mut scene, "coil", &mut ctx); + press(&mut scene, KeyCode::Char('d'), &mut ctx); + assert_eq!(scene.crawl.turns, 0, "dropping is free"); + assert!(scene.inventory.is_some(), "and leaves it open"); + assert_eq!(scene.crawl.here().len(), 1); + find(&mut scene, "medpatch", &mut ctx); + press(&mut scene, KeyCode::Char('a'), &mut ctx); + assert_eq!(scene.crawl.turns, 1, "a medpatch takes a turn"); + assert!(scene.inventory.is_none(), "and closes it"); + } + + /// Picks out the first thing in the open inventory called `what`. + fn find(scene: &mut Derelict, what: &str, ctx: &mut Ctx) { + press(scene, KeyCode::Char('/'), ctx); + press(scene, KeyCode::Esc, ctx); + press(scene, KeyCode::Char('/'), ctx); + for c in what.chars() { + press(scene, KeyCode::Char(c), ctx); + } + press(scene, KeyCode::Enter, ctx); + } + + #[test] + fn firing_takes_a_turn_and_spends_a_round() { + let mut ctx = ctx(); + let mut scene = Derelict::new(3); + press(&mut scene, KeyCode::Char('f'), &mut ctx); + scene.mode = Mode::Aim(scene.crawl.player.offset(2, 0)); + ctx.ticks = 100; + press(&mut scene, KeyCode::Char('f'), &mut ctx); + assert_eq!(scene.crawl.turns, 1); + assert_eq!(scene.crawl.gear.gun.loaded, scene.crawl.gear.gun.gun.unwrap().magazine - 1); + assert!(!scene.crawl.volley.is_empty()); + assert_eq!(scene.volley_at, 100); + assert!(scene.in_flight(100_000).is_empty(), "long since landed"); + } + + #[test] + fn your_record_counts_wrecks_kills_and_how_you_left_them() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + let kind = scene.crawl.monsters[0].kind.name; + scene.crawl.monsters[0].hp = 0; + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert_eq!(ctx.record.slain.get(kind), Some(&1)); + let key = key_toward_airlock(&scene); + press(&mut scene, key, &mut ctx); + + let mut scene = Derelict::board(3, &mut ctx); + abandon(&mut scene, &mut ctx); + + ctx.hold.gear.add(medpatches(2)); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.bleeding = 100; + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert!(matches!(scene.crawl.outcome, Some(Outcome::Died { .. }))); + assert!(screen(&scene, &ctx).contains(&ctx.character.ship_name())); + assert_eq!(ctx.hold.gear.medpatches(), 0, "brought down, and they stay behind"); + + let r = &ctx.record; + assert_eq!((r.boarded, r.escaped, r.abandoned, r.downed), (3, 1, 1, 1)); + } + + #[test] + fn until_you_leave_the_wreck_your_hold_is_as_you_left_it() { + let mut ctx = ctx(); + ctx.hold.gear.add(medpatches(2)); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.gear = Gear::default(); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert_eq!(ctx.hold.gear.medpatches(), 2, "so a game saved now still has them"); + } + + #[test] + fn what_you_learn_you_keep() { + let mut ctx = ctx(); + let mut scene = Derelict::board(3, &mut ctx); + scene.crawl.known.insert(("rust-mite", Dmg::Blunt)); + press(&mut scene, KeyCode::Char('.'), &mut ctx); + assert!(ctx.bestiary.contains(&("rust-mite", Dmg::Blunt))); + let scene = Derelict::board(4, &mut ctx); + assert!(scene.crawl.known.contains(&("rust-mite", Dmg::Blunt))); + } } -- cgit v1.2.3