summaryrefslogtreecommitdiff
path: root/src/scenes/derelict.rs
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-11 15:24:37 -0800
committerskylar <me@casper>2026-11-11 15:24:37 -0800
commit9ccf951473e21a8b90c25161156fa09a9b227a7b (patch)
treee7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/derelict.rs
parentd36c54d996f3c14ae811312dbbe621e12a39edde (diff)
Orrery 0.1.0: the galaxy, flight, cities, inventory and saves
Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src/scenes/derelict.rs')
-rw-r--r--src/scenes/derelict.rs1117
1 files changed, 1005 insertions, 112 deletions
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<Inventory>,
+ /// 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<String>)>,
+ /// The tick this turn's shots set off, for drawing them in flight.
+ volley_at: u64,
+ /// The menu `esc` opens.
+ pause: Option<Pause>,
}
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<Derelict> {
+ 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<String> =
+ 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<Action> {
+ 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<Action> {
+ 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<Pos> {
+ let c = &self.crawl;
+ let mut targets: Vec<Pos> =
+ 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::<Vec<_>>(),
- )
+ /// The monsters you can see, nearest first, with how hurt they are.
+ fn in_view_lines<'a>(&'a self, lines: &mut Vec<Line<'a>>) {
+ 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<Line<'a>>) {
+ 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<Line<'static>> {
+ 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<Stack> {
+ 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<Stack> {
+ // 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<Stack> = 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<Stack> =
+ 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<Line> = 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<Stack> =
+ 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<String>) {
+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<Line>) {
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<String>) {
.border_style(Style::new().fg(Color::Yellow))
.title(Line::from(title.to_string()).bold())
.padding(Padding::horizontal(1));
- let lines: Vec<Line> = 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)));
+ }
}