summaryrefslogtreecommitdiff
path: root/src/scenes/aboard.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/aboard.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/aboard.rs')
-rw-r--r--src/scenes/aboard.rs1314
1 files changed, 1314 insertions, 0 deletions
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<Cell> {
+ 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<Item = Pos> {
+ 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<Rgb>)> {
+ 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<Wreck>),
+}
+
+/// 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<String>,
+ /// How many things have been said, ever: the log itself stops growing.
+ said: usize,
+ outside: Option<Outside>,
+ bins: Vec<(u8, Pos)>,
+ shelf: Vec<Pos>,
+ /// 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<Pause>,
+}
+
+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<Wreck>) -> 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<String> =
+ 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<Item = &Item> {
+ 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<String> =
+ 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<Rgb>)) -> (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<Pos> =
+ 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<Rgb>)],
+ ) -> (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<Line> = 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<Pos, Pos> {
+ 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<u32> = 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()
+ }
+}