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/aboard.rs | 1314 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1314 insertions(+) create mode 100644 src/scenes/aboard.rs (limited to 'src/scenes/aboard.rs') diff --git a/src/scenes/aboard.rs b/src/scenes/aboard.rs new file mode 100644 index 0000000..49dc61d --- /dev/null +++ b/src/scenes/aboard.rs @@ -0,0 +1,1314 @@ +//! Aboard your own ship, on foot. +//! +//! Landing leaves you in the cockpit. To go ashore you walk aft through the +//! ship to the airlock in the stern, and to take off again you walk back +//! forward and sit in the pilot's seat. Out in space you can get up from +//! the controls whenever you're flying slowly: the ship holds station while +//! you walk about, and if a wreck is alongside, the airlock boards it. +//! +//! The ship is drawn by hand below, in the shape of the dart you fly: the +//! cockpit in the nose, an engine room in each swept-back wing, and the +//! airlock in the notch between them. The hold has a bin for every five +//! tons of ore you can carry, more as cargo racks are fitted, and shows +//! what's in them. The shelf in the crew quarters shows the artifacts in +//! your lockers, and the med cabinet the medpatches. +//! +//! `i` opens the inventory, with the lockers beside what you carry. What +//! you put down on the deck stays where you left it, for good. `<` walks +//! you forward to the pilot's seat and `>` aft to the airlock, stopping +//! beside them. + +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; + +use super::character::Sheet; +use super::derelict::{Derelict, camera}; +use super::inventory::{self, Beside, Inventory, Reply, View}; +use super::keys::{self, ACCENT, DIM}; +use super::pause::{Choice, Pause}; +use super::port::Port; +use super::sky::{mix, rgb}; +use super::town::TownScene; +use super::{Scene, Transition}; +use crate::app::{Ctx, Hold}; +use crate::dungeon::Pos; +use crate::items::{self, Item, Stack}; +use crate::space::locale::ORES; +use crate::space::system::{Landing, PlaceKind, Rgb, Wreck}; +use crate::trade; + +const PANEL_WIDTH: u16 = 32; +const LOG_HEIGHT: u16 = 6; +/// The most steps one run takes. +const RUN_LIMIT: usize = 60; +/// Tons of ore to a bin in the hold. +const BIN_TONS: u32 = 5; +/// The middle row. With the nose to the east, rows above it are the port +/// side and rows below it starboard. +const MIDSHIPS: i32 = 9; + +/// Your ship, nose to the east, one character to a cell: +/// +/// `#` hull, `x` bulkhead, `.` deck, `:` engine-room grating, `/` doorway, +/// `A` the airlock, `w` windscreen, `P` the pilot's seat, `c` crew seats, +/// `n` `N` `r` the flight, nav and comms consoles, `E` the drives, +/// `k` workbenches, `O` ore bins, `1` to `3` the bins each set of cargo +/// racks adds, `b` bunks, `B` the med-bay couch, `m` the med cabinet, +/// `*` the shelf for artifacts, `l` lockers, `C` crates, `t` the galley +/// table, `h` chairs, `s` the galley, `f` a fern. +#[rustfmt::skip] +const LAYOUT: [&str; 19] = [ + "##############", + "#:EE:::::::::#####", + "#:EE:::::k:::::::#####", + "#::::::::::::::::::::#", + "####xxx/xxxxxxxxxxxxx####################", + " #..............x.bb.bb.bb.**lxmm...BB#####", + " #.OOOO.11.22.33x.............x.......x...#####", + " #..............x.............x.......x.......#####", + " #..............xxxxxxx/xxxxxxxxxx/xxxx.......cNNN#www", + " A............../...................../..........Pnnnw", + " #..............xxxxxxx/xxxxxxxxxx/xxxx.......crrr#www", + " #..............x....hh.......x.......x.......#####", + " #.OOOO.11.22.33x...htth......x......Cx...#####", + " #..............xsss.........fxlllll.C#####", + "####xxx/xxxxxxxxxxxxx####################", + "#::::::::::::::::::::#", + "#:EE:::::k:::::::#####", + "#:EE:::::::::#####", + "##############", +]; + +const HEIGHT: i32 = LAYOUT.len() as i32; +const WIDTH: i32 = { + let mut width = 0; + let mut i = 0; + while i < LAYOUT.len() { + if LAYOUT[i].len() > width { + width = LAYOUT[i].len(); + } + i += 1; + } + width as i32 +}; + +/// A room: its name, and its top left and bottom right corners. +struct Room(&'static str, (i32, i32), (i32, i32)); + +/// The first room that holds a cell names it, so doorways go with the +/// corridor or the hold. +const ROOMS: [Room; 9] = [ + Room("the cockpit", (41, 6), (54, 12)), + Room("the corridor", (19, 8), (40, 10)), + Room("the crew quarters", (19, 5), (31, 7)), + Room("the galley", (19, 11), (31, 13)), + Room("the med-bay", (33, 5), (39, 7)), + Room("the stores", (33, 11), (39, 13)), + Room("the hold", (3, 4), (18, 14)), + Room("the port engine room", (1, 1), (20, 3)), + Room("the starboard engine room", (1, 15), (20, 17)), +]; + +/// Box-drawing glyphs for walls, by which neighbours they join: 1 north, +/// 2 east, 4 south, 8 west. The hull is drawn in double lines. +const DOUBLE: [char; 16] = + ['■', '║', '═', '╚', '║', '║', '╔', '╠', '═', '╝', '═', '╩', '╗', '╣', '╦', '╬']; +const SINGLE: [char; 16] = + ['■', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼']; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cell { + Void, + Hull, + Wall, + Deck, + Grating, + Door, + Airlock, + Window, + PilotSeat, + /// An ore bin: one the ship came with (set 0), or one of the `n`th set + /// of cargo racks, which is bare deck until they're fitted. + Bin(u8), + Fixture(Fixture), +} + +impl Cell { + fn walkable(self) -> bool { + matches!(self, Cell::Deck | Cell::Grating | Cell::Door) + } + + /// Whether a wall beside it joins up with it. + fn joins(self) -> bool { + matches!(self, Cell::Hull | Cell::Wall | Cell::Airlock | Cell::Window) + } +} + +/// Things you bump into to look at. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fixture { + FlightConsole, + NavConsole, + Comms, + CrewSeat, + Drive, + Workbench, + Bunk, + Couch, + MedCabinet, + Shelf, + Locker, + Crate, + Table, + Chair, + Galley, + Fern, +} + +fn parse(ch: u8) -> Option { + use Fixture as F; + let fixture = match ch { + b' ' => return Some(Cell::Void), + b'#' => return Some(Cell::Hull), + b'x' => return Some(Cell::Wall), + b'.' => return Some(Cell::Deck), + b':' => return Some(Cell::Grating), + b'/' => return Some(Cell::Door), + b'A' => return Some(Cell::Airlock), + b'w' => return Some(Cell::Window), + b'P' => return Some(Cell::PilotSeat), + b'O' => return Some(Cell::Bin(0)), + b'1'..=b'3' => return Some(Cell::Bin(ch - b'0')), + b'n' => F::FlightConsole, + b'N' => F::NavConsole, + b'r' => F::Comms, + b'c' => F::CrewSeat, + b'E' => F::Drive, + b'k' => F::Workbench, + b'b' => F::Bunk, + b'B' => F::Couch, + b'm' => F::MedCabinet, + b'*' => F::Shelf, + b'l' => F::Locker, + b'C' => F::Crate, + b't' => F::Table, + b'h' => F::Chair, + b's' => F::Galley, + b'f' => F::Fern, + _ => return None, + }; + Some(Cell::Fixture(fixture)) +} + +/// The cell at `p` as drawn, before any cargo racks are fitted. Off the +/// map is `Void`. +fn layout(p: Pos) -> Cell { + let row = usize::try_from(p.y).ok().and_then(|y| LAYOUT.get(y)); + let ch = row.and_then(|row| usize::try_from(p.x).ok().and_then(|x| row.as_bytes().get(x))); + ch.and_then(|&ch| parse(ch)).unwrap_or(Cell::Void) +} + +/// Every cell drawn as `ch`, row by row. +fn positions(ch: u8) -> impl Iterator { + LAYOUT.iter().enumerate().flat_map(move |(y, row)| { + row.bytes() + .enumerate() + .filter(move |&(_, c)| c == ch) + .map(move |(x, _)| Pos::new(x as i32, y as i32)) + }) +} + +fn seat() -> Pos { + positions(b'P').next().expect("the ship has a pilot's seat") +} + +fn room_at(p: Pos) -> Option<&'static str> { + ROOMS + .iter() + .find(|Room(_, (x0, y0), (x1, y1))| { + (*x0..=*x1).contains(&p.x) && (*y0..=*y1).contains(&p.y) + }) + .map(|room| room.0) +} + +/// Every bin, with its set, in the order they fill: the ship's own, from +/// aft forward, then each set of racks in turn. +fn bins() -> Vec<(u8, Pos)> { + let mut bins: Vec<(u8, Pos)> = (0..HEIGHT) + .flat_map(|y| (0..WIDTH).map(move |x| Pos::new(x, y))) + .filter_map(|p| match layout(p) { + Cell::Bin(set) => Some((set, p)), + _ => None, + }) + .collect(); + bins.sort_by_key(|&(set, p)| (set, p.x, p.y)); + bins +} + +/// How many sets of cargo racks are fitted. +fn racks(hold: &Hold) -> u8 { + let extra = hold.ore_capacity.saturating_sub(Hold::BASE_ORE_CAPACITY); + u8::try_from(extra / trade::RACK_TONS).unwrap_or(u8::MAX) +} + +/// The ore in the first `count` bins: tons in each, and the colour of the +/// ore that fills most of it. +fn bin_contents(hold: &Hold, count: usize) -> Vec<(u32, Option)> { + let mut bins = vec![(0, None::<(Rgb, u32)>); count]; + let mut i = 0; + for (name, &tons) in &hold.ore { + let colour = ORES.iter().find(|o| o.name == *name).map_or((150, 138, 124), |o| o.rgb); + let mut left = tons; + while left > 0 && i < count { + let (filled, most) = &mut bins[i]; + let pour = left.min(BIN_TONS - *filled); + *filled += pour; + if most.is_none_or(|(_, t)| pour > t) { + *most = Some((colour, pour)); + } + left -= pour; + if *filled == BIN_TONS { + i += 1; + } + } + } + bins.into_iter().map(|(tons, most)| (tons, most.map(|(colour, _)| colour))).collect() +} + +/// Where the ship is while you walk about it. +#[derive(Clone, Debug, PartialEq)] +pub enum Berth { + /// Set down at a place: a city's landing field, a station's dock, or + /// open ground where no one lives. + Landed(usize), + /// Holding station out in space, with a wreck alongside if there's one. + Adrift(Option), +} + +/// Where you went through the airlock to. While you're there, its scene is +/// the one on top of this. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Outside { + City, + Port, + Wreck, +} + +pub struct Aboard { + berth: Berth, + /// Where the ship is, for the panel: "landed on Qeth", "holding station + /// in orbit around Qeth, a desert world". + whereabouts: String, + player: Pos, + log: Vec, + /// How many things have been said, ever: the log itself stops growing. + said: usize, + outside: Option, + bins: Vec<(u8, Pos)>, + shelf: Vec, + /// Open over the ship when you're looking through what you carry, with + /// the lockers or the deck beside it. + inventory: Option<(Inventory, Beside)>, + /// The menu `esc` opens. + pause: Option, +} + +impl Aboard { + /// Just landed or docked at `place`: you get up from the pilot's seat. + pub fn landed(ctx: &Ctx, place: usize) -> Aboard { + let name = &ctx.system.places[place].name; + let (whereabouts, arrival) = match ctx.system.landing(place) { + Landing::Capital(_) => ( + format!("landed on {name}"), + format!("You set down on {name}, on the landing field outside its capital."), + ), + Landing::Market(_) => (format!("docked at {name}"), format!("You dock at {name}.")), + _ => { + (format!("landed on {name}"), format!("You set down on {name}. No one lives here.")) + } + }; + let mut scene = Aboard::new(Berth::Landed(place), whereabouts); + scene.say(arrival); + scene.say("You unbuckle and get up. The airlock is aft, through the hold.".into()); + scene + } + + /// Back aboard, landed at `place`, in a game you've come back to. + pub fn resumed(ctx: &Ctx, place: usize) -> Aboard { + let mut scene = Aboard::landed(ctx, place); + scene.log.clear(); + let ship = ctx.character.ship_name(); + let name = &ctx.system.places[place].name; + let berth = match ctx.system.landing(place) { + Landing::Market(_) => format!("docked at {name}"), + _ => format!("landed on {name}"), + }; + scene.say(format!("You're aboard {ship}, {berth}, on day {:.0}.", ctx.day.floor())); + scene.say("The airlock is aft, through the hold: [>] walks you there.".into()); + scene + } + + /// Up from the controls out in space, where the ship is `what` ("in + /// orbit around Qeth, a desert world"), with `wreck` alongside if + /// there's one. + pub fn adrift(what: &str, wreck: Option) -> Aboard { + let alongside = wreck.as_ref().map(|w| capitalise(&w.name)); + let mut scene = Aboard::new(Berth::Adrift(wreck), format!("holding station {what}")); + scene.say("You set the ship to hold station, unbuckle and get up.".into()); + if let Some(name) = alongside { + scene.say(format!("{name} drifts alongside. The airlock is aft, through the hold.")); + } + scene + } + + fn new(berth: Berth, whereabouts: String) -> Aboard { + Aboard { + berth, + whereabouts, + player: seat().offset(-1, 0), + log: Vec::new(), + said: 0, + outside: None, + bins: bins(), + shelf: positions(b'*').collect(), + inventory: None, + pause: None, + } + } + + fn say(&mut self, text: String) { + self.log.push(text); + self.said += 1; + if self.log.len() > 50 { + self.log.remove(0); + } + } + + /// The cell at `p`, with racks that aren't fitted left out. + fn cell(&self, p: Pos, hold: &Hold) -> Cell { + match layout(p) { + Cell::Bin(set) if set > racks(hold) => Cell::Deck, + cell => cell, + } + } + + /// Back in through the airlock, if you were out. + fn come_back(&mut self) { + let Some(outside) = self.outside.take() else { return }; + let text = match outside { + Outside::City => "You climb the ramp and seal the airlock behind you.", + Outside::Port => "You come back aboard and seal the airlock.", + Outside::Wreck => "The airlock cycles, and you're back aboard your own ship.", + }; + self.say(text.into()); + } + + fn step(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { + let to = self.player.offset(dx, dy); + match self.cell(to, &ctx.hold) { + cell if cell.walkable() => { + self.player = to; + if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, to)) { + self.say(text); + } + } + Cell::PilotSeat => return Transition::Pop, + Cell::Airlock => return self.airlock(ctx), + Cell::Window => { + let text = self.view_out(ctx); + self.say(text); + } + Cell::Bin(_) => self.say(hold_text(&ctx.hold)), + // The weapons locker opens the lockers. + Cell::Fixture(Fixture::Locker) if to.y < MIDSHIPS => { + self.say("You open the lockers.".into()); + self.inventory = Some((Inventory::new(), Beside::Lockers)); + } + Cell::Fixture(fixture) => { + let text = self.about(fixture, to, ctx); + self.say(text); + } + _ => {} + } + Transition::None + } + + /// `g`: picks up what's on the deck where you stand, or lists it if + /// there's more than one thing. + fn pick_up(&mut self, ctx: &mut Ctx) { + let here = inventory::lying(&ctx.hold.deck, self.player); + match &here[..] { + [] => self.say("There's nothing here to pick up.".into()), + [stack] => { + let command = inventory::Command::Fetch(0, stack.count); + let text = self.carry_out(command, Beside::Floor("on the deck"), ctx); + self.say(text); + } + _ => self.inventory = Some((Inventory::beside(), Beside::Floor("on the deck"))), + } + } + + /// Does what the inventory asks, with the lockers or the deck beside you. + fn carry_out(&mut self, command: inventory::Command, beside: Beside, ctx: &mut Ctx) -> String { + let h = &mut ctx.hold; + let mut floor = inventory::gather(&mut h.deck, self.player); + let lockers = (beside == Beside::Lockers).then_some(&mut h.lockers); + let text = inventory::carry_out(command, &mut h.gear, lockers, &mut floor, Some(beside)); + inventory::scatter(&mut h.deck, self.player, floor); + text + } + + /// A key while the inventory's open. + fn inventory_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { + let Some((inv, beside)) = &mut self.inventory else { return }; + let beside = *beside; + let floor = inventory::lying(&ctx.hold.deck, self.player); + let view = view(ctx, beside, &floor); + match inv.key(key, &view) { + Reply::Stay => {} + Reply::Close => self.inventory = None, + Reply::Sort(sort) => ctx.inventory_sort = sort, + Reply::Do(command) => { + let text = self.carry_out(command, beside, ctx); + if let Some((inv, _)) = &mut self.inventory { + inv.note = Some(text.clone()); + } + self.say(text); + } + } + } + + /// Steps until something changes: a wall, a doorway into another room, + /// something said. + fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { + for _ in 0..RUN_LIMIT { + let (before, room, said) = (self.player, room_at(self.player), self.said); + let transition = self.step(dx, dy, ctx); + if !matches!(transition, Transition::None) { + return transition; + } + if self.player == before || self.said != said || room_at(self.player) != room { + break; + } + } + Transition::None + } + + /// Out through the airlock: into the city, the station or the wreck + /// alongside, if there's anywhere to go. + fn airlock(&mut self, ctx: &mut Ctx) -> Transition { + match self.berth { + Berth::Landed(place) => { + if matches!(ctx.system.landing(place), Landing::Capital(_)) { + self.outside = Some(Outside::City); + Transition::Push(Box::new(TownScene::new(ctx, place))) + } else { + self.outside = Some(Outside::Port); + Transition::Push(Box::new(Port::new(ctx, place))) + } + } + Berth::Adrift(None) => { + self.say( + "There's nothing outside the airlock but vacuum. Bring the ship alongside \ + a wreck to board it." + .into(), + ); + Transition::None + } + Berth::Adrift(Some(ref wreck)) => { + let wreck = wreck.clone(); + if ctx.stripped.contains(&wreck.seed) { + let text = + format!("You've already stripped {}. Nothing's left aboard.", wreck.name); + self.say(text); + Transition::None + } else { + let seed = wreck.seed; + ctx.stripped.insert(seed); + self.outside = Some(Outside::Wreck); + Transition::Push(Box::new(Derelict::board(seed, ctx))) + } + } + } + } + + /// `<` or `>`: walks to stand beside the pilot's seat or the airlock, + /// by the shortest way, so one more step takes off or goes out. + fn walk_to(&mut self, goal: Cell, ctx: &Ctx) { + let (what, way) = match goal { + Cell::PilotSeat => ("the pilot's seat ╥", "forward"), + _ => ("the airlock Θ", "aft"), + }; + let beside = |p: Pos| { + [(0, -1), (1, 0), (0, 1), (-1, 0)] + .into_iter() + .any(|(dx, dy)| layout(p.offset(dx, dy)) == goal) + }; + if beside(self.player) { + self.say(format!("You're beside {what}.")); + return; + } + // Breadth first, the way you walk: a step in any of eight directions. + let mut seen = std::collections::HashSet::from([self.player]); + let mut queue = std::collections::VecDeque::from([self.player]); + while let Some(p) = queue.pop_front() { + if beside(p) { + self.player = p; + self.say(format!("You walk {way} to {what}.")); + if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, p)) { + self.say(text); + } + return; + } + for dy in -1..=1 { + for dx in -1..=1 { + let next = p.offset(dx, dy); + if self.cell(next, &ctx.hold).walkable() && seen.insert(next) { + queue.push_back(next); + } + } + } + } + self.say(format!("There's no way through to {what}.")); + } + + /// Esc: which way to go to leave. + fn way_out(&self) -> String { + match self.berth { + Berth::Landed(_) => "To take off, sit in the pilot's seat ╥, forward in the cockpit \ + ([<] walks you there). To go out, it's the airlock Θ, aft ([>])." + .into(), + Berth::Adrift(_) => "To fly on, sit in the pilot's seat ╥, forward in the cockpit \ + ([<] walks you there)." + .into(), + } + } + + fn view_out(&self, ctx: &Ctx) -> String { + match self.berth { + Berth::Landed(place) => { + let name = &ctx.system.places[place].name; + match ctx.system.landing(place) { + Landing::Capital(_) => format!( + "Through the windscreen: the landing field, and beyond it the walls of \ + {name}'s capital." + ), + Landing::Market(_) => { + format!( + "Through the windscreen: the docking bay of {name}, and dockhands at work." + ) + } + _ => format!("Through the windscreen: {name}, empty as far as you can see."), + } + } + Berth::Adrift(_) => { + format!("Through the windscreen: the stars. The ship is {}.", self.whereabouts) + } + } + } + + /// What you see when you bump `fixture`, at `at`. + fn about(&self, fixture: Fixture, at: Pos, ctx: &Ctx) -> String { + let h = &ctx.hold; + let port = at.y < MIDSHIPS; + match fixture { + Fixture::FlightConsole => "The flight console. Sit in the pilot's seat to fly.".into(), + Fixture::NavConsole => { + format!("The nav console: the {} system, day {:.1}.", ctx.system.name, ctx.day) + } + Fixture::Comms => "The comms board. Nothing on the open channels but static.".into(), + Fixture::CrewSeat if port => "The co-pilot's seat, with a jacket slung over it.".into(), + Fixture::CrewSeat => "The engineer's seat, worn smooth.".into(), + Fixture::Drive => { + let side = if port { "port" } else { "starboard" }; + match self.berth { + Berth::Landed(_) => format!("The {side} drive, cold and ticking as it cools."), + Berth::Adrift(_) => { + format!("The {side} drive, idling, holding the ship on station.") + } + } + } + Fixture::Workbench => { + "A workbench, with tools clipped to the bulkhead above it.".into() + } + Fixture::Bunk => "A bunk, made up tight.".into(), + Fixture::Couch => "The med-bay couch, its straps buckled down.".into(), + Fixture::MedCabinet => match stored(h, |i| matches!(i, Item::Medpatch)).count() { + 0 => "The med cabinet is empty. Doctors in the cities sell medpatches.".into(), + 1 => "The med cabinet holds a medpatch. Pack it (i) to take it aboard a wreck." + .into(), + n => format!( + "The med cabinet holds {n} medpatches. Pack them (i) to take them aboard \ + a wreck." + ), + }, + Fixture::Shelf => { + let names: Vec = + stored(h, |i| matches!(i, Item::Artifact(_))).map(|i| i.name()).collect(); + if names.is_empty() { + "An empty shelf, for the strange things you find aboard wrecks.".into() + } else { + format!("On the shelf: {}.", listed(&names)) + } + } + Fixture::Locker => "Lockers of spare parts, coolant and air filters.".into(), + Fixture::Crate => "A crate of ration bricks, lashed to the deck.".into(), + Fixture::Table => "The galley table, bolted to the deck.".into(), + Fixture::Chair => "A chair, bolted down in case the gravity goes.".into(), + Fixture::Galley => "The galley: a hotplate, a kettle and a water still.".into(), + Fixture::Fern => "A potted fern. Against the odds, it's thriving.".into(), + } + } +} + +/// One of each thing in the lockers that's `kind`, counting stacks out. +fn stored(h: &Hold, kind: impl Fn(&Item) -> bool) -> impl Iterator { + h.lockers + .iter() + .filter(move |s| kind(&s.item)) + .flat_map(|s| std::iter::repeat_n(&s.item, s.count as usize)) +} + +/// What the inventory's shown: what you carry, beside the lockers or the +/// deck where you stand (`floor`). +fn view<'a>(ctx: &'a Ctx, beside: Beside, floor: &'a [Stack]) -> View<'a> { + let pile = match beside { + Beside::Lockers => &ctx.hold.lockers[..], + Beside::Floor(_) | Beside::Rack => floor, + }; + View { + gear: &ctx.hold.gear, + beside: (beside == Beside::Lockers || !pile.is_empty()).then_some((beside, pile)), + known: &ctx.bestiary, + hurt: false, + shop: None, + sort: ctx.inventory_sort, + } +} + +/// What the ore bins hold. +fn hold_text(h: &Hold) -> String { + let tons = h.ore_tons(); + if tons == 0 { + return format!( + "The ore bins are empty. They take {} t; the mining laser fills them.", + h.ore_capacity + ); + } + let kinds: Vec = + h.ore.iter().filter(|&(_, &t)| t > 0).map(|(name, t)| format!("{t} t of {name}")).collect(); + format!("The ore bins hold {}: {tons} of {} t.", listed(&kinds), h.ore_capacity) +} + +/// "a, b and c". +fn listed(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +fn capitalise(s: &str) -> String { + let mut chars = s.chars(); + chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) +} + +impl Scene for Aboard { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + self.come_back(); + if let Some(pause) = &mut self.pause { + let Some(choice) = pause.key(key) else { return Transition::None }; + self.pause = None; + return match choice { + Choice::Quit => Transition::Title, + Choice::Here | Choice::Resume => Transition::None, + }; + } + if self.inventory.is_some() { + self.inventory_key(key, ctx); + return Transition::None; + } + match key.code { + KeyCode::Char('c' | 'C') => return Transition::Push(Box::new(Sheet::new())), + KeyCode::Char('i') => { + self.inventory = Some((Inventory::new(), Beside::Lockers)); + return Transition::None; + } + KeyCode::Char('g' | ',') => { + self.pick_up(ctx); + return Transition::None; + } + KeyCode::Char('<') => { + self.walk_to(Cell::PilotSeat, ctx); + return Transition::None; + } + KeyCode::Char('>') => { + self.walk_to(Cell::Airlock, ctx); + return Transition::None; + } + KeyCode::Esc => { + self.pause = Some(Pause::new(None, Some(self.way_out()))); + return Transition::None; + } + _ => {} + } + match keys::movement(key) { + Some((dx, dy, true)) => self.run(dx, dy, ctx), + Some((dx, dy, false)) => self.step(dx, dy, ctx), + None => Transition::None, + } + } + + fn tick(&mut self, _ctx: &mut Ctx) -> Transition { + self.come_back(); + Transition::None + } + + 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.draw_map(frame.buffer_mut(), map, ctx); + self.draw_log(frame, log); + self.draw_panel(frame, panel, ctx); + if let Some((inv, beside)) = &self.inventory { + let floor = inventory::lying(&ctx.hold.deck, self.player); + inv.render(frame, frame.area(), &view(ctx, *beside, &floor)); + } + if let Some(pause) = &self.pause { + pause.render(frame, frame.area()); + } + } +} + +/// A wall's glyph, joined up with the walls around it. +fn wall_glyph(p: Pos, glyphs: &[char; 16]) -> char { + let joins = |dx, dy| usize::from(layout(p.offset(dx, dy)).joins()); + glyphs[joins(0, -1) | joins(1, 0) << 1 | joins(0, 1) << 2 | joins(-1, 0) << 3] +} + +/// A console's lights, a few of them off at any moment. +fn lights(p: Pos, ticks: u64, on: Rgb) -> Rgb { + let k = (ticks / 10) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 3; + if k.rem_euclid(5) == 0 { mix(on, (0, 0, 0), 0.55) } else { on } +} + +fn bin_look((tons, ore): (u32, Option)) -> (char, Rgb) { + match ore { + Some(colour) if tons >= BIN_TONS => ('■', colour), + Some(colour) if tons > 0 => ('▪', colour), + _ => ('□', (96, 100, 116)), + } +} + +/// What's out of the windscreen when you're down: the ground, or the dock. +fn outside_rgb(ctx: &Ctx, place: usize) -> Rgb { + let s = &ctx.system; + match s.places[place].kind { + PlaceKind::Planet(p) => s.planets[p].kind.rgb, + PlaceKind::Moon(p, m) => s.planets[p].moons[m].kind.rgb, + PlaceKind::Station(_) | PlaceKind::Belt => (150, 160, 180), + } +} + +impl Aboard { + fn draw_map(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { + let left = camera(self.player.x, WIDTH, i32::from(area.width)); + let top = camera(self.player.y, HEIGHT, i32::from(area.height)); + let racks = racks(&ctx.hold); + let bins: Vec = + self.bins.iter().filter(|&&(set, _)| set <= racks).map(|&(_, p)| p).collect(); + let contents = bin_contents(&ctx.hold, bins.len()); + 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, colour, bold) = if p == self.player { + ('@', (255, 255, 255), true) + } else { + self.look(p, ctx, &bins, &contents) + }; + let cell = &mut buf[(area.x + sx, area.y + sy)]; + cell.set_char(glyph).set_fg(rgb(colour)); + if bold { + cell.modifier.insert(Modifier::BOLD); + } + } + } + } + + /// How the cell at `p` looks, leaving out you. + fn look( + &self, + p: Pos, + ctx: &Ctx, + bins: &[Pos], + contents: &[(u32, Option)], + ) -> (char, Rgb, bool) { + let ticks = ctx.ticks; + // What's put down on the deck, the last thing on top. + if let Some((_, stack)) = ctx.hold.deck.iter().rev().find(|(at, _)| *at == p) { + let (glyph, colour) = inventory::look(&stack.item); + return (glyph, colour, false); + } + let (glyph, colour) = match self.cell(p, &ctx.hold) { + Cell::Void => (' ', (0, 0, 0)), + Cell::Hull => (wall_glyph(p, &DOUBLE), (122, 134, 160)), + Cell::Wall => (wall_glyph(p, &SINGLE), (150, 150, 162)), + // Where racks would be bolted, if you bought them. + Cell::Deck if matches!(layout(p), Cell::Bin(_)) => ('·', (84, 88, 104)), + Cell::Deck => ('.', (100, 104, 120)), + Cell::Grating => (':', (84, 88, 104)), + Cell::Door => ('\'', (216, 170, 86)), + Cell::Airlock => return ('Θ', (90, 222, 200), true), + Cell::PilotSeat => return ('╥', (240, 200, 110), true), + Cell::Window => match self.berth { + Berth::Landed(place) => ('▒', mix(outside_rgb(ctx, place), (170, 200, 230), 0.3)), + Berth::Adrift(_) => { + let twinkle = (ticks / 20) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 11; + if twinkle.rem_euclid(13) == 0 { + ('·', (220, 226, 255)) + } else { + ('░', (44, 52, 96)) + } + } + }, + Cell::Bin(_) => { + let i = bins.iter().position(|&b| b == p); + bin_look(i.and_then(|i| contents.get(i).copied()).unwrap_or((0, None))) + } + Cell::Fixture(fixture) => self.fixture_look(fixture, p, ctx), + }; + (glyph, colour, false) + } + + fn fixture_look(&self, fixture: Fixture, p: Pos, ctx: &Ctx) -> (char, Rgb) { + let ticks = ctx.ticks; + let h = &ctx.hold; + match fixture { + Fixture::FlightConsole => ('▤', lights(p, ticks, (110, 220, 150))), + Fixture::NavConsole => ('▤', lights(p, ticks, (110, 180, 240))), + Fixture::Comms => ('▤', lights(p, ticks, (230, 170, 90))), + Fixture::CrewSeat => ('╥', (150, 140, 124)), + Fixture::Drive => match self.berth { + Berth::Landed(_) => ('¤', (96, 104, 130)), + Berth::Adrift(_) => { + let k = (ticks as f64 / 6.0 + f64::from(p.x + p.y)).sin() * 0.5 + 0.5; + ('¤', mix((80, 110, 210), (190, 220, 255), k)) + } + }, + Fixture::Workbench => ('╦', (150, 156, 172)), + Fixture::Bunk => ('▬', (120, 140, 190)), + Fixture::Couch => ('▬', (210, 214, 226)), + Fixture::MedCabinet if stored(h, |i| matches!(i, Item::Medpatch)).next().is_some() => { + ('+', (240, 96, 96)) + } + Fixture::MedCabinet => ('+', (120, 64, 64)), + Fixture::Shelf => { + let i = self.shelf.iter().position(|&s| s == p).unwrap_or(0); + if i < stored(h, |i| matches!(i, Item::Artifact(_))).count() { + ('*', (255, 214, 92)) + } else { + ('≡', (130, 112, 84)) + } + } + Fixture::Locker => ('▪', (130, 140, 160)), + Fixture::Crate => ('■', (170, 140, 96)), + Fixture::Table => ('╤', (150, 156, 172)), + Fixture::Chair => ('╥', (120, 124, 140)), + Fixture::Galley => ('◘', (170, 176, 190)), + Fixture::Fern => ('♣', (90, 170, 80)), + } + } + + fn draw_log(&self, frame: &mut Frame, area: Rect) { + let shown = usize::from(area.height.saturating_sub(1)); + let first = self.log.len().saturating_sub(shown); + let lines: Vec = self.log[first..] + .iter() + .enumerate() + .map(|(i, text)| { + let age = self.log.len() - first - 1 - i; + let line = Line::from(text.as_str()); + match age { + 0 => line.white(), + 1 | 2 => line.gray(), + _ => line.dark_gray(), + } + }) + .collect(); + let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray()); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); + } + + fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(format!(" {} ", ctx.character.ship_name())).bold().fg(ACCENT)) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + let (seat, airlock) = match self.berth { + Berth::Landed(place) => ( + "pilot's seat ╥: take off", + match ctx.system.landing(place) { + Landing::Market(_) => "airlock Θ: to the station", + _ => "airlock Θ: go out", + }, + ), + Berth::Adrift(Some(_)) => ("pilot's seat ╥: fly on", "airlock Θ: board the wreck"), + Berth::Adrift(None) => ("pilot's seat ╥: fly on", "airlock Θ: only vacuum"), + }; + let hints = keys::packed( + &[ + ("arrows hjkl yubn", "walk"), + ("shift", "run"), + ("g", "pick up"), + ("i", "inventory"), + ("c", "character"), + ("bump", "look at things"), + ("<", seat), + (">", airlock), + ("esc", "menu: save and quit"), + ], + inner.width, + ); + let [info, help] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(hints.len() as u16 + 1)]) + .areas(inner); + + let h = &ctx.hold; + let label = |text: &'static str| Span::raw(text).fg(DIM); + let room = room_at(self.player).unwrap_or("aboard"); + let mut lines = vec![ + Line::from("your ship").fg(DIM), + Line::from(self.whereabouts.clone()).fg(DIM), + Line::default(), + Line::from(capitalise(room)).fg(ACCENT), + ]; + if room != "the cockpit" { + lines.push(Line::from(" the cockpit is forward").fg(DIM)); + } + let somewhere_to_go = !matches!(self.berth, Berth::Adrift(None)); + if room != "the hold" && somewhere_to_go { + lines.push(Line::from(" the airlock is aft").fg(DIM)); + } + lines.extend([ + Line::default(), + Line::from(vec![label("money "), Span::raw(format!("{} ¤", h.scrip))]), + Line::from(vec![ + label("ore "), + Span::raw(format!("{}/{} t", h.ore_tons(), h.ore_capacity)), + ]), + Line::from(vec![ + label("fuel "), + Span::raw(format!("{}/{} jumps", h.fuel, h.fuel_capacity)), + ]), + Line::from(vec![label("lenses "), Span::raw(ctx.lenses.to_string())]), + Line::from(vec![ + label("carrying "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.gear.carried()), + items::kg(h.gear.capacity) + )), + ]), + Line::from(vec![ + label("lockers "), + Span::raw(format!( + "{}/{} kg", + items::kg(h.locker_mass()), + items::kg(items::LOCKER_CAPACITY) + )), + ]), + Line::from(vec![label("medpatches "), Span::raw(h.medpatches().to_string())]), + ]); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); + + // A line between what's here and the keys. + let [_, help] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(help); + frame.render_widget(Paragraph::new(hints), help); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + use std::collections::{HashMap, VecDeque}; + + use super::*; + use crate::names; + use crate::scenes::tests::test_ctx; + use crate::seed; + + fn press(scene: &mut Aboard, ctx: &mut Ctx, code: KeyCode) -> Transition { + scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + fn key_towards(dx: i32, dy: i32) -> KeyCode { + match (dx, dy) { + (-1, 0) => KeyCode::Left, + (1, 0) => KeyCode::Right, + (0, -1) => KeyCode::Up, + (0, 1) => KeyCode::Down, + (-1, -1) => KeyCode::Home, + (1, -1) => KeyCode::PageUp, + (-1, 1) => KeyCode::End, + _ => KeyCode::PageDown, + } + } + + /// The steps from `from` to every cell you can walk to, as a way back: + /// each cell's previous one. + fn paths(scene: &Aboard, hold: &Hold, from: Pos) -> HashMap { + let mut came_from = HashMap::from([(from, from)]); + let mut queue = VecDeque::from([from]); + while let Some(p) = queue.pop_front() { + for n in p.neighbours() { + if scene.cell(n, hold).walkable() && !came_from.contains_key(&n) { + came_from.insert(n, p); + queue.push_back(n); + } + } + } + came_from + } + + /// Walks, one key at a time, to `to`, and then a step on towards + /// `bump`, returning what that last step did. + fn walk(scene: &mut Aboard, ctx: &mut Ctx, to: Pos, bump: Pos) -> Transition { + let came_from = paths(scene, &ctx.hold, scene.player); + let mut route = vec![to]; + while let Some(&before) = came_from.get(route.last().unwrap()) + && before != scene.player + { + route.push(before); + } + assert!(came_from.contains_key(&to), "can't walk to {to:?}"); + for &p in route.iter().rev() { + let (dx, dy) = (p.x - scene.player.x, p.y - scene.player.y); + assert!(matches!(press(scene, ctx, key_towards(dx, dy)), Transition::None)); + assert_eq!(scene.player, p); + } + press(scene, ctx, key_towards(bump.x - to.x, bump.y - to.y)) + } + + fn airlock() -> Pos { + positions(b'A').next().unwrap() + } + + #[test] + fn the_layout_is_whole() { + for row in LAYOUT { + for ch in row.bytes() { + assert!(parse(ch).is_some(), "{:?} isn't a cell", ch as char); + } + } + assert_eq!(positions(b'P').count(), 1); + assert_eq!(positions(b'A').count(), 1); + // Everything walkable is in a room, and you can get to all of it, + // and to everything you can bump, from the pilot's seat. + let mut ctx = test_ctx(); + ctx.hold.ore_capacity = Hold::BASE_ORE_CAPACITY; + let scene = Aboard::adrift("nowhere", None); + let reached = paths(&scene, &ctx.hold, scene.player); + for y in 0..HEIGHT { + for x in 0..WIDTH { + let p = Pos::new(x, y); + let cell = scene.cell(p, &ctx.hold); + if cell.walkable() { + assert!(reached.contains_key(&p), "can't reach {p:?}"); + assert!(room_at(p).is_some(), "{p:?} isn't in a room"); + } + // The flight console is in front of the pilot's seat, out of + // reach unless you're sitting in it. + let bumpable = matches!(cell, Cell::Fixture(_) | Cell::Bin(_) | Cell::Airlock) + && cell != Cell::Fixture(Fixture::FlightConsole); + if bumpable || cell == Cell::PilotSeat { + assert!(p.neighbours().any(|n| reached.contains_key(&n)), "can't reach {p:?}"); + } + } + } + } + + #[test] + fn there_is_a_bin_for_every_five_tons() { + let mut hold = Hold::default(); + let all = bins(); + for _ in 0..4 { + let racks = racks(&hold); + let count = all.iter().filter(|&&(set, _)| set <= racks).count() as u32; + assert_eq!(count * BIN_TONS, hold.ore_capacity); + hold.ore_capacity += trade::RACK_TONS; + } + hold.ore_capacity = Hold::BASE_ORE_CAPACITY; + hold.ore.insert("nickel", 12); + hold.ore.insert("iridium", 3); + let contents = bin_contents(&hold, 8); + let tons: Vec = contents.iter().map(|c| c.0).collect(); + assert_eq!(tons, [5, 5, 5, 0, 0, 0, 0, 0]); + let colour = |name: &str| ORES.iter().find(|o| o.name == name).unwrap().rgb; + // Ores go in by name: the first bin has 3 t of iridium in it, and + // 2 t of nickel on top. + assert_eq!(contents[0].1, Some(colour("iridium"))); + assert_eq!(contents[2].1, Some(colour("nickel"))); + assert_eq!(contents[3].1, None); + } + + #[test] + fn you_walk_aft_to_go_ashore_and_forward_to_take_off() { + let mut ctx = test_ctx(); + let home = ctx.system.home_place(); + let mut scene = Aboard::landed(&ctx, home); + assert_eq!(room_at(scene.player), Some("the cockpit")); + let door = airlock().offset(1, 0); + let out = walk(&mut scene, &mut ctx, door, airlock()); + assert!(matches!(out, Transition::Push(_)), "the airlock opens on the city"); + assert_eq!(scene.outside, Some(Outside::City)); + // Back from the city, you're just inside the airlock. + scene.tick(&mut ctx); + assert!(scene.log.last().unwrap().contains("seal the airlock")); + assert_eq!(scene.player, door); + let stand = seat().offset(-1, 0); + assert!(matches!(walk(&mut scene, &mut ctx, stand, seat()), Transition::Pop)); + } + + #[test] + fn angle_brackets_walk_you_to_the_airlock_and_the_seat() { + let mut ctx = test_ctx(); + let home = ctx.system.home_place(); + let mut scene = Aboard::landed(&ctx, home); + press(&mut scene, &mut ctx, KeyCode::Char('>')); + assert_eq!(layout(scene.player.offset(-1, 0)), Cell::Airlock, "beside it, not through"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('h')), Transition::Push(_))); + press(&mut scene, &mut ctx, KeyCode::Char('<')); + assert_eq!(layout(scene.player.offset(1, 0)), Cell::PilotSeat); + let said = scene.said; + press(&mut scene, &mut ctx, KeyCode::Char('<')); + assert_eq!(scene.said, said + 1, "you're told you're there already"); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Pop)); + } + + #[test] + fn running_stops_at_doorways() { + let mut ctx = test_ctx(); + let mut scene = Aboard::adrift("nowhere", None); + let run = |scene: &mut Aboard, ctx: &mut Ctx| { + scene.key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), ctx) + }; + run(&mut scene, &mut ctx); + assert_eq!(scene.player, Pos::new(40, MIDSHIPS), "out of the cockpit"); + run(&mut scene, &mut ctx); + assert_eq!(scene.player, Pos::new(18, MIDSHIPS), "down the corridor"); + assert!(matches!(run(&mut scene, &mut ctx), Transition::None)); + assert_eq!(scene.player, airlock().offset(1, 0), "across the hold, to vacuum"); + assert!(scene.log.last().unwrap().contains("vacuum")); + } + + #[test] + fn the_airlock_boards_a_wreck_alongside_once() { + let mut ctx = test_ctx(); + let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); + let mut scene = Aboard::adrift("nowhere", Some(wreck.clone())); + scene.player = airlock().offset(1, 0); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::Push(_))); + assert!(ctx.stripped.contains(&wreck.seed)); + assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::None)); + assert!(scene.log.last().unwrap().contains("already stripped")); + } + + #[test] + fn the_hold_and_the_shelf_show_what_you_carry() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let shelf = positions(b'*').next().unwrap(); + scene.player = shelf.offset(0, 1); + press(&mut scene, &mut ctx, KeyCode::Up); + assert!(scene.log.last().unwrap().starts_with("An empty shelf")); + let artifact = names::artifact(&mut seed::rng(4)); + let name = artifact.name().to_string(); + ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); + press(&mut scene, &mut ctx, KeyCode::Up); + assert_eq!(scene.log.last().unwrap(), &format!("On the shelf: {name}.")); + + ctx.hold.ore.insert("nickel", 7); + let (_, bin) = bins()[0]; + scene.player = bin.offset(0, 1); + press(&mut scene, &mut ctx, KeyCode::Up); + assert_eq!(scene.log.last().unwrap(), "The ore bins hold 7 t of nickel: 7 of 40 t."); + // Racks you haven't bought are bare deck you can walk over. + let (_, rack) = *bins().iter().find(|&&(set, _)| set == 1).unwrap(); + assert!(scene.cell(rack, &ctx.hold).walkable()); + ctx.hold.ore_capacity += trade::RACK_TONS; + assert_eq!(scene.cell(rack, &ctx.hold), Cell::Bin(1)); + } + + #[test] + fn what_you_put_down_on_the_deck_stays_there() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let wire = + Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 9, mass: 2000 }); + ctx.hold.gear.add(Stack { item: wire.clone(), count: 3 }); + let start = scene.player; + + press(&mut scene, &mut ctx, KeyCode::Char('i')); + assert!(scene.inventory.is_some()); + let shown = screen(&scene, &ctx); + assert!(shown.contains("YOU ·") && shown.contains("LOCKERS"), "{shown}"); + for c in "/wire".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Enter); + for c in "2d".chars() { + press(&mut scene, &mut ctx, KeyCode::Char(c)); + } + press(&mut scene, &mut ctx, KeyCode::Char('m')); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.inventory.is_some(), "esc clears what you were finding first"); + press(&mut scene, &mut ctx, KeyCode::Esc); + assert!(scene.inventory.is_none()); + assert_eq!( + inventory::lying(&ctx.hold.deck, start), + [Stack { item: wire.clone(), count: 2 }] + ); + assert_eq!(ctx.hold.lockers, [Stack::one(wire.clone())]); + assert!(ctx.hold.gear.pack.iter().all(|s| s.item != wire)); + + // Walk off and it's on the deck; walk back on and you're told. + press(&mut scene, &mut ctx, KeyCode::Char('h')); + assert_ne!(scene.player, start); + let (glyph, _, _) = scene.look(start, &ctx, &[], &[]); + assert_eq!(glyph, '('); + press(&mut scene, &mut ctx, KeyCode::Char('l')); + assert!(scene.log.last().unwrap().contains("2 coils of wire here")); + press(&mut scene, &mut ctx, KeyCode::Char('g')); + assert!(ctx.hold.deck.is_empty()); + assert!(ctx.hold.gear.pack.contains(&Stack { item: wire, count: 2 })); + } + + #[test] + fn the_weapons_locker_opens_the_lockers() { + let mut ctx = test_ctx(); + let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); + let locker = positions(b'l').find(|p| p.y < MIDSHIPS).unwrap(); + let from = locker.neighbours().find(|&n| scene.cell(n, &ctx.hold).walkable()).unwrap(); + scene.player = from; + press(&mut scene, &mut ctx, key_towards(locker.x - from.x, locker.y - from.y)); + assert!(matches!(scene.inventory, Some((_, Beside::Lockers)))); + } + + fn screen(scene: &Aboard, ctx: &Ctx) -> String { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); + terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); + terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() + } +} -- cgit v1.2.3