summaryrefslogtreecommitdiff
path: root/src/scenes
diff options
context:
space:
mode:
Diffstat (limited to 'src/scenes')
-rw-r--r--src/scenes/aboard.rs1314
-rw-r--r--src/scenes/character.rs821
-rw-r--r--src/scenes/derelict.rs1117
-rw-r--r--src/scenes/flight.rs1163
-rw-r--r--src/scenes/forge.rs10
-rw-r--r--src/scenes/galaxy_map.rs929
-rw-r--r--src/scenes/inventory.rs1808
-rw-r--r--src/scenes/keys.rs97
-rw-r--r--src/scenes/mod.rs159
-rw-r--r--src/scenes/pause.rs160
-rw-r--r--src/scenes/port.rs330
-rw-r--r--src/scenes/saves.rs278
-rw-r--r--src/scenes/sky.rs460
-rw-r--r--src/scenes/system_map.rs830
-rw-r--r--src/scenes/title.rs178
-rw-r--r--src/scenes/town.rs2509
-rw-r--r--src/scenes/zz_dump.rs62
17 files changed, 12066 insertions, 159 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()
+ }
+}
diff --git a/src/scenes/character.rs b/src/scenes/character.rs
new file mode 100644
index 0000000..1ed2b11
--- /dev/null
+++ b/src/scenes/character.rs
@@ -0,0 +1,821 @@
+//! Characters: making a new one, and the sheet that shows who you are and
+//! what you've done.
+//!
+//! A new character gets a name, a people they come from and a ship. Each is
+//! suggested, ← and → step through more suggestions, and you can type your
+//! own instead. They also get a universe to play in, which is only a seed:
+//! type in someone else's to fly the same system they do.
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Padding, Paragraph, Wrap};
+
+use super::flight;
+use super::forge::colour;
+use super::inventory::look;
+use super::keys::{self, ACCENT, DIM};
+use super::sky::rgb;
+use super::{Scene, Transition};
+use crate::app::{Ctx, Kept};
+use crate::dungeon::arms::Dmg;
+use crate::dungeon::crawl::{Affinity, MONSTERS};
+use crate::items::{self, Gear, Item};
+use crate::names::Culture;
+use crate::save::{self, Character};
+use crate::seed;
+use crate::space;
+
+/// The most letters a name, yours or your ship's, can have.
+const NAME_LENGTH: usize = 28;
+/// The most digits a universe can have. Any more and it might not fit.
+const UNIVERSE_DIGITS: usize = 18;
+/// Universes on offer stay short enough to read aloud, like the ones the
+/// clock picks.
+const UNIVERSES: u64 = 1_000_000_000;
+
+/// A line you can type into. Until you do, it holds a suggestion, and the
+/// first letter you type replaces it.
+#[derive(Clone, Debug, Default)]
+struct Entry {
+ text: String,
+ typed: bool,
+}
+
+impl Entry {
+ fn typed(text: &str) -> Entry {
+ Entry { text: text.to_string(), typed: true }
+ }
+
+ fn suggest(&mut self, text: String) {
+ self.text = text;
+ self.typed = false;
+ }
+
+ /// Takes a key meant for the text: a letter `allowed`, backspace, or
+ /// ctrl+u to clear the line. Says whether it was one.
+ fn edit(&mut self, key: KeyEvent, allowed: fn(char) -> bool, longest: usize) -> bool {
+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
+ match key.code {
+ KeyCode::Char('u') if ctrl => self.text.clear(),
+ KeyCode::Char(c) if !ctrl && allowed(c) => {
+ if !self.typed {
+ self.text.clear();
+ }
+ let space_wasted = c == ' ' && (self.text.is_empty() || self.text.ends_with(' '));
+ if self.text.chars().count() < longest && !space_wasted {
+ self.text.push(c);
+ }
+ }
+ KeyCode::Backspace => {
+ self.text.pop();
+ }
+ _ => return false,
+ }
+ self.typed = true;
+ true
+ }
+}
+
+fn any_letter(c: char) -> bool {
+ !c.is_control()
+}
+
+fn digit(c: char) -> bool {
+ c.is_ascii_digit()
+}
+
+/// A name as typed, with runs of spaces closed up.
+fn tidy(name: &str) -> String {
+ name.split_whitespace().collect::<Vec<_>>().join(" ")
+}
+
+/// A ship's name without the "the" that goes in front of it anyway.
+fn ship(name: &str) -> String {
+ let name = tidy(name);
+ if name.eq_ignore_ascii_case("the") {
+ return String::new();
+ }
+ match name.get(..4) {
+ Some(the) if the.eq_ignore_ascii_case("the ") => name[4..].to_string(),
+ _ => name,
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Row {
+ Name,
+ Origin,
+ Ship,
+ Universe,
+ Begin,
+}
+
+const ROWS: [Row; 5] = [Row::Name, Row::Origin, Row::Ship, Row::Universe, Row::Begin];
+
+/// Making a new character.
+pub struct Create {
+ row: Row,
+ name: Entry,
+ origin: Culture,
+ ship: Entry,
+ universe: Entry,
+ /// What the universe makes: "the Qeth system, in Qaari space".
+ system: String,
+ /// Suggestions are `derive(suggestions, what, n)`, so ← brings back
+ /// what → stepped past.
+ suggestions: u64,
+ /// Which suggestion each line is on.
+ names: u64,
+ ships: u64,
+ universes: u64,
+ problem: Option<String>,
+}
+
+impl Create {
+ pub fn new(ctx: &Ctx) -> Create {
+ let suggestions = seed::from_clock();
+ // A game started for a universe (`orrery play --seed`) makes its
+ // first character there. Otherwise every character gets their own.
+ let universe = if ctx.slot.is_none() {
+ ctx.seed
+ } else {
+ seed::derive(suggestions, "universe", 0) % UNIVERSES
+ };
+ let mut create = Create {
+ row: Row::Name,
+ name: Entry::default(),
+ origin: space::home_system(universe).culture,
+ ship: Entry::default(),
+ universe: Entry { text: universe.to_string(), typed: false },
+ system: String::new(),
+ suggestions,
+ names: 0,
+ ships: 0,
+ universes: 0,
+ problem: None,
+ };
+ create.describe_universe();
+ create.suggest_name();
+ create.suggest_ship();
+ create
+ }
+
+ fn rng(&self, what: &str, n: u64) -> seed::Rng {
+ seed::rng(seed::derive(self.suggestions, what, n))
+ }
+
+ fn suggest_name(&mut self) {
+ let name = self.origin.person(&mut self.rng("name", self.names));
+ self.name.suggest(name);
+ }
+
+ fn suggest_ship(&mut self) {
+ let ship = self.origin.word(&mut self.rng("ship", self.ships));
+ self.ship.suggest(ship);
+ }
+
+ fn describe_universe(&mut self) {
+ self.system = match self.universe.text.parse::<u64>() {
+ Ok(universe) => {
+ let system = space::home_system(universe);
+ format!("the {} system, in {} space", system.name, system.culture.adjective())
+ }
+ Err(_) => "a universe is a number".to_string(),
+ };
+ }
+
+ fn move_row(&mut self, by: isize) {
+ let at = ROWS.iter().position(|&r| r == self.row).unwrap_or(0);
+ let to = (at as isize + by).clamp(0, ROWS.len() as isize - 1);
+ self.row = ROWS[to as usize];
+ }
+
+ /// ← and →: another suggestion, or another people.
+ fn step(&mut self, by: i64) {
+ let next = |n: u64| n.saturating_add_signed(by);
+ match self.row {
+ Row::Name => {
+ self.names = next(self.names);
+ self.suggest_name();
+ }
+ Row::Ship => {
+ self.ships = next(self.ships);
+ self.suggest_ship();
+ }
+ Row::Origin => {
+ let at = Culture::ALL.iter().position(|&c| c == self.origin).unwrap_or(0);
+ let count = Culture::ALL.len() as i64;
+ self.origin = Culture::ALL[(at as i64 + by).rem_euclid(count) as usize];
+ // Names you haven't chosen follow where you're from.
+ if !self.name.typed {
+ self.suggest_name();
+ }
+ if !self.ship.typed {
+ self.suggest_ship();
+ }
+ }
+ Row::Universe => {
+ self.universes = next(self.universes);
+ let universe = seed::derive(self.suggestions, "universe", self.universes);
+ self.universe.suggest((universe % UNIVERSES).to_string());
+ self.describe_universe();
+ }
+ Row::Begin => {}
+ }
+ }
+
+ /// Makes the character and starts them off aboard their ship, landed on
+ /// their universe's home world.
+ fn begin(&mut self, ctx: &mut Ctx) -> Transition {
+ let name = tidy(&self.name.text);
+ let ship = ship(&self.ship.text);
+ let universe = self.universe.text.parse::<u64>();
+ let problem = if name.is_empty() {
+ Some((Row::Name, "You'll need a name.".to_string()))
+ } else if ship.is_empty() {
+ Some((Row::Ship, "Your ship will need a name.".to_string()))
+ } else if universe.is_err() {
+ Some((Row::Universe, "A universe is a number.".to_string()))
+ } else {
+ None
+ };
+ if let Some((row, problem)) = problem {
+ self.row = row;
+ self.problem = Some(problem);
+ return Transition::None;
+ }
+ let universe = universe.unwrap();
+ let character = Character { name, origin: self.origin, ship };
+ let slot = ctx.saves.as_ref().map(|dir| save::new_slot(dir, &character, universe));
+ if slot.as_ref().is_some_and(|path| path.exists()) {
+ self.row = Row::Name;
+ self.problem = Some(format!(
+ "There's already a {} in universe {universe}. Load them from the title, or \
+ choose another name.",
+ character.name
+ ));
+ return Transition::None;
+ }
+ // Whoever you were playing, as you left them.
+ ctx.autosave();
+ ctx.begin(universe, character);
+ ctx.slot = slot;
+ ctx.landed = true;
+ Transition::Replace(flight::resume(ctx))
+ }
+}
+
+impl Scene for Create {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ self.problem = None;
+ match key.code {
+ KeyCode::Esc => return Transition::Pop,
+ KeyCode::Up | KeyCode::BackTab => self.move_row(-1),
+ KeyCode::Down | KeyCode::Tab => self.move_row(1),
+ KeyCode::Enter if self.row == Row::Begin => return self.begin(ctx),
+ KeyCode::Enter => self.move_row(1),
+ KeyCode::Left => self.step(-1),
+ KeyCode::Right => self.step(1),
+ _ => match self.row {
+ Row::Name => {
+ self.name.edit(key, any_letter, NAME_LENGTH);
+ }
+ Row::Ship => {
+ self.ship.edit(key, any_letter, NAME_LENGTH);
+ }
+ Row::Universe => {
+ if self.universe.edit(key, digit, UNIVERSE_DIGITS) {
+ self.describe_universe();
+ }
+ }
+ Row::Origin | Row::Begin => {}
+ },
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, _ctx: &Ctx) {
+ let tint = colour(self.origin);
+ let chosen = |row: Row| self.row == row;
+ let label = |row: Row, text: &'static str| {
+ let marker = if chosen(row) { "› " } else { " " };
+ let style = if chosen(row) { Style::new().fg(ACCENT).bold() } else { Style::new() };
+ vec![Span::styled(marker, style), Span::styled(format!("{text:<10}"), style.fg(DIM))]
+ };
+ let cursor =
+ |row: Row| if chosen(row) { Span::raw("▏").fg(ACCENT) } else { Span::raw("") };
+ let value = |entry: &Entry| {
+ let span = Span::raw(entry.text.clone());
+ if entry.typed { span.bold() } else { span }
+ };
+
+ let mut name = label(Row::Name, "name");
+ name.extend([value(&self.name), cursor(Row::Name)]);
+ let mut origin = label(Row::Origin, "from");
+ if chosen(Row::Origin) {
+ origin.extend([
+ Span::raw("‹ ").fg(DIM),
+ Span::raw(self.origin.name()).fg(tint).bold(),
+ Span::raw(" ›").fg(DIM),
+ ]);
+ } else {
+ origin.push(Span::raw(self.origin.name()).fg(tint));
+ }
+ let mut ship = label(Row::Ship, "ship");
+ ship.extend([Span::raw("the "), value(&self.ship), cursor(Row::Ship)]);
+ let mut universe = label(Row::Universe, "universe");
+ universe.extend([value(&self.universe), cursor(Row::Universe)]);
+ let begin = if chosen(Row::Begin) {
+ Line::from(vec![Span::raw("› ").fg(ACCENT).bold(), "[ begin ]".fg(ACCENT).bold()])
+ } else {
+ Line::from(" [ begin ]")
+ };
+
+ let hint = match self.row {
+ Row::Name => {
+ keys::line(&[("type", "your name"), ("← →", "another"), ("enter", "next")])
+ }
+ Row::Origin => keys::line(&[("← →", "where you're from"), ("enter", "next")]),
+ Row::Ship => {
+ keys::line(&[("type", "your ship's name"), ("← →", "another"), ("enter", "next")])
+ }
+ Row::Universe => {
+ keys::line(&[("type", "a universe"), ("← →", "another"), ("enter", "next")])
+ }
+ Row::Begin => keys::line(&[("enter", "begin")]),
+ };
+ let indent = |text: String| Line::from(format!(" {text}")).fg(DIM);
+ let mut lines = vec![
+ Line::from("Who are you, and what do you fly?").fg(DIM).italic(),
+ Line::default(),
+ Line::from(name),
+ Line::from(origin),
+ indent(self.origin.blurb().to_string()).italic(),
+ Line::from(ship),
+ Line::from(universe),
+ indent(self.system.clone()),
+ Line::default(),
+ begin,
+ Line::default(),
+ ];
+ match &self.problem {
+ Some(problem) => lines.push(Line::from(problem.as_str()).fg(Color::LightRed)),
+ None => lines.push(hint),
+ }
+ lines.push(keys::line(&[("↑↓ tab", "choose a line"), ("esc", "back")]));
+
+ let area = boxed(frame.area(), 70, lines.len() as u16 + 4);
+ let block = Block::bordered()
+ .border_style(Style::new().fg(DIM))
+ .title(Line::from(" A NEW CHARACTER ").fg(ACCENT).bold())
+ .padding(Padding::uniform(1));
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }).block(block), area);
+ }
+}
+
+/// A box up to `width` by `height`, in the middle of `area`.
+fn boxed(area: Rect, width: u16, height: u16) -> Rect {
+ let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
+ let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area);
+ area
+}
+
+/// "rust-mites", "hollow crewmen", "bulkhead sentries".
+fn plural(name: &str) -> String {
+ if let Some(stem) = name.strip_suffix("man") {
+ format!("{stem}men")
+ } else if let Some(stem) = name.strip_suffix('y')
+ && !stem.ends_with(['a', 'e', 'o', 'u'])
+ {
+ format!("{stem}ies")
+ } else {
+ format!("{name}s")
+ }
+}
+
+/// Your character sheet: who you are, what you carry, what you've done and
+/// what you've learned. You can rename your ship here.
+#[derive(Default)]
+pub struct Sheet {
+ renaming: Option<Entry>,
+ /// Aboard a wreck: its name, and what you carry there, which is only
+ /// yours once you're back through the airlock.
+ wreck: Option<(String, Gear)>,
+}
+
+impl Sheet {
+ pub fn new() -> Sheet {
+ Sheet::default()
+ }
+
+ /// The sheet as it stands aboard the wreck `name`, carrying `gear`.
+ pub fn aboard(name: &str, gear: Gear) -> Sheet {
+ Sheet { renaming: None, wreck: Some((name.to_string(), gear)) }
+ }
+
+ /// What you carry: aboard a wreck, what you have there.
+ fn gear<'a>(&'a self, ctx: &'a Ctx) -> &'a Gear {
+ self.wreck.as_ref().map_or(&ctx.hold.gear, |(_, gear)| gear)
+ }
+
+ fn who(&self, ctx: &Ctx) -> Vec<Line<'static>> {
+ let c = &ctx.character;
+ let ship = match &self.renaming {
+ Some(entry) => vec![
+ Span::raw("the "),
+ Span::raw(entry.text.clone()).bold(),
+ Span::raw("▏").fg(ACCENT),
+ ],
+ None => vec![Span::raw(c.ship_name())],
+ };
+ let mut calling = vec![Span::raw(format!("{}, captain of ", c.calling()))];
+ calling.extend(ship);
+ let place = &ctx.system.places[ctx.location].name;
+ let whereabouts = match &self.wreck {
+ Some((name, _)) => format!("aboard {name}"),
+ None if ctx.landed => "set down".into(),
+ None => "flying".into(),
+ };
+ vec![
+ Line::from(c.name.clone()).fg(colour(c.origin)).bold(),
+ Line::from(calling),
+ Line::from(format!(
+ "day {:.1} in the {} system, {whereabouts} at {place}",
+ ctx.day, ctx.system.name
+ ))
+ .fg(DIM),
+ Line::from(format!("universe {}", ctx.seed)).fg(DIM),
+ ]
+ }
+
+ fn hold(&self, ctx: &Ctx) -> Vec<Line<'static>> {
+ let h = &ctx.hold;
+ let g = self.gear(ctx);
+ // What's on you and in the lockers, like `Hold::kept`, but with
+ // what you carry wherever you are.
+ let kept = || {
+ let pack = g.pack.iter().enumerate().map(|(i, s)| (Kept::Pack(i), s));
+ pack.chain(h.lockers.iter().enumerate().map(|(i, s)| (Kept::Lockers(i), s)))
+ };
+ let count = |matching: fn(&Item) -> bool| -> u32 {
+ kept().filter(|(_, s)| matching(&s.item)).map(|(_, s)| s.count).sum()
+ };
+ let row = |label: &str, value: String| {
+ Line::from(vec![Span::raw(format!("{label:<11}")).fg(DIM), Span::raw(value)])
+ };
+ let mut ore = format!("{}/{} t", h.ore_tons(), h.ore_capacity);
+ if !h.ore.is_empty() {
+ let each: Vec<String> = h.ore.iter().map(|(name, t)| format!("{t} {name}")).collect();
+ ore = format!("{ore}: {}", each.join(", "));
+ }
+ let mut lines = vec![
+ heading("HOLD"),
+ row("money", format!("{} ¤", h.scrip)),
+ row("ore", ore),
+ row("fuel", format!("{}/{} jumps", h.fuel, h.fuel_capacity)),
+ row("medpatches", count(|i| *i == Item::Medpatch).to_string()),
+ row("lenses", ctx.lenses.to_string()),
+ row("carrying", format!("{}/{} kg", items::kg(g.carried()), items::kg(g.capacity))),
+ row(
+ "lockers",
+ format!("{}/{} kg", items::kg(h.locker_mass()), items::kg(items::LOCKER_CAPACITY)),
+ ),
+ row("artifacts", count(|i| matches!(i, Item::Artifact(_))).to_string()),
+ ];
+ if h.debt > 0 {
+ lines.insert(3, row("owed", format!("{} ¤, for a tow", h.debt)));
+ }
+ lines.extend(kept().filter_map(|(at, s)| {
+ let Item::Artifact(a) = &s.item else { return None };
+ let name = Span::raw(format!(" * {}{}", a.name(), at.note()));
+ Some(Line::from(if a.identified { name.fg(Color::LightYellow) } else { name.italic() }))
+ }));
+ lines.push(Line::default());
+ lines.push(heading("READY AND WORN"));
+ for w in [&g.melee, &g.gun] {
+ let item = Item::Weapon(w.clone());
+ let (glyph, colour) = look(&item);
+ lines.push(Line::from(vec![
+ Span::raw(format!("{glyph} ")).fg(rgb(colour)),
+ Span::raw(w.name()),
+ Span::raw(format!(" {}", w.damage_text())).fg(DIM),
+ ]));
+ }
+ for (slot, a) in &g.worn {
+ let (glyph, colour) = look(&Item::Armor(a.clone()));
+ lines.push(Line::from(vec![
+ Span::raw(format!("{glyph} ")).fg(rgb(colour)),
+ Span::raw(a.name()),
+ Span::raw(format!(
+ " {}, {}, {}",
+ slot.name(),
+ a.guards_text(),
+ a.durability_text()
+ ))
+ .fg(DIM),
+ ]));
+ }
+ let packed = g.pack.len();
+ if packed > 0 {
+ let s = if packed == 1 { "" } else { "s" };
+ lines.push(Line::from(format!("and {packed} thing{s} in your pack [i]")).fg(DIM));
+ }
+ lines
+ }
+
+ fn record(ctx: &Ctx) -> Vec<Line<'static>> {
+ let r = &ctx.record;
+ let row = |label: &str, n: u32| {
+ Line::from(vec![Span::raw(format!("{label:<16}")).fg(DIM), Span::raw(n.to_string())])
+ };
+ let mut lines = vec![
+ heading("RECORD"),
+ row("wrecks boarded", r.boarded),
+ row("got back from", r.escaped),
+ row("brought down", r.downed),
+ row("abandoned", r.abandoned),
+ row("killed", r.slain_total()),
+ row("systems seen", ctx.visited.len() as u32),
+ ];
+ let mut slain: Vec<(&String, &u32)> = r.slain.iter().collect();
+ slain.sort_by_key(|&(name, n)| (std::cmp::Reverse(*n), name.clone()));
+ lines.extend(slain.into_iter().map(|(name, &n)| {
+ let name = if n == 1 { name.clone() } else { plural(name) };
+ Line::from(format!(" {n} {name}"))
+ }));
+ lines.push(Line::default());
+ lines.push(heading("WHAT YOU KNOW"));
+ let mut any = false;
+ for kind in MONSTERS.iter() {
+ let known: Vec<Dmg> =
+ Dmg::ALL.into_iter().filter(|&d| ctx.bestiary.contains(&(kind.name, d))).collect();
+ if known.is_empty() {
+ continue;
+ }
+ any = true;
+ let mut spans = vec![Span::raw(format!("{} ", kind.glyph)).fg(rgb(kind.rgb))];
+ spans.push(Span::raw(kind.name));
+ lines.push(Line::from(spans));
+ let mut takes = vec![Span::raw(" ")];
+ for (i, dmg) in known.into_iter().enumerate() {
+ let (word, tint) = match kind.affinity(dmg) {
+ Affinity::Weak => ("weak to", Color::Green),
+ Affinity::Normal => ("takes", Color::Gray),
+ Affinity::Resists => ("resists", Color::Yellow),
+ Affinity::Immune => ("immune to", Color::Red),
+ };
+ if i > 0 {
+ takes.push(Span::raw(" · ").fg(DIM));
+ }
+ takes.push(Span::raw(format!("{word} {}", dmg.name())).fg(tint));
+ }
+ lines.push(Line::from(takes));
+ }
+ if !any {
+ lines.push(
+ Line::from(
+ "Nothing yet. Hit what lives aboard wrecks with different kinds of weapon \
+ to learn what hurts them.",
+ )
+ .fg(DIM),
+ );
+ }
+ lines
+ }
+}
+
+fn heading(text: &'static str) -> Line<'static> {
+ Line::from(text).fg(ACCENT).dim().bold()
+}
+
+impl Scene for Sheet {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ if let Some(entry) = &mut self.renaming {
+ match key.code {
+ KeyCode::Esc => self.renaming = None,
+ KeyCode::Enter => {
+ let name = ship(&entry.text);
+ if !name.is_empty() {
+ ctx.character.ship = name;
+ ctx.autosave();
+ }
+ self.renaming = None;
+ }
+ _ => {
+ entry.edit(key, any_letter, NAME_LENGTH);
+ }
+ }
+ return Transition::None;
+ }
+ match key.code {
+ KeyCode::Char('r') => {
+ self.renaming = Some(Entry::typed(&ctx.character.ship));
+ Transition::None
+ }
+ KeyCode::Esc | KeyCode::Enter | KeyCode::Char('c' | 'q') => Transition::Pop,
+ _ => Transition::None,
+ }
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let (hold, record) = (self.hold(ctx), Sheet::record(ctx));
+ // Tall enough for both columns side by side, or one under the
+ // other when it's too narrow for that; the rest is the header,
+ // the footer and the border.
+ let wide = frame.area().width >= 74;
+ let body = if wide { hold.len().max(record.len()) } else { hold.len() + 1 + record.len() };
+ let area = boxed(frame.area(), 100, body as u16 + 9);
+ let block = Block::bordered()
+ .border_style(Style::new().fg(DIM))
+ .title(Line::from(" CHARACTER ").fg(ACCENT).bold())
+ .padding(Padding::horizontal(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let [who, _, body, footer] = Layout::vertical([
+ Constraint::Length(4),
+ Constraint::Length(1),
+ Constraint::Fill(1),
+ Constraint::Length(1),
+ ])
+ .areas(inner);
+ frame.render_widget(Paragraph::new(self.who(ctx)).wrap(Wrap { trim: false }), who);
+
+ if wide {
+ let [left, _, right] = Layout::horizontal([
+ Constraint::Fill(1),
+ Constraint::Length(2),
+ Constraint::Fill(1),
+ ])
+ .areas(body);
+ frame.render_widget(Paragraph::new(hold).wrap(Wrap { trim: false }), left);
+ frame.render_widget(Paragraph::new(record).wrap(Wrap { trim: false }), right);
+ } else {
+ let mut lines = hold;
+ lines.push(Line::default());
+ lines.extend(record);
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), body);
+ }
+
+ let hints = if self.renaming.is_some() {
+ keys::line(&[
+ ("type", "a new name"),
+ ("enter", "keep it"),
+ ("esc", "leave it as it was"),
+ ])
+ } else {
+ keys::line(&[("r", "rename your ship"), ("esc", "back")])
+ };
+ frame.render_widget(Paragraph::new(hints), footer);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+
+ use super::*;
+ use crate::term::KeyboardCaps;
+
+ fn ctx() -> Ctx {
+ Ctx::new(7, KeyboardCaps { key_release: false })
+ }
+
+ fn press(scene: &mut dyn Scene, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ fn typing(scene: &mut dyn Scene, ctx: &mut Ctx, text: &str) {
+ for c in text.chars() {
+ press(scene, ctx, KeyCode::Char(c));
+ }
+ }
+
+ fn screen(scene: &dyn Scene, ctx: &Ctx) -> String {
+ let mut terminal = Terminal::new(TestBackend::new(110, 44)).unwrap();
+ terminal.draw(|frame| scene.render(frame, ctx)).unwrap();
+ let buffer = terminal.backend().buffer();
+ let width = buffer.area.width as usize;
+ let cells: Vec<&str> = buffer.content.iter().map(|c| c.symbol()).collect();
+ cells.chunks(width).map(|row| row.concat() + "\n").collect()
+ }
+
+ #[test]
+ fn you_name_yourself_and_your_ship() {
+ let dir = save::scratch("create");
+ let mut ctx = ctx();
+ ctx.saves = Some(dir.clone());
+ let mut create = Create::new(&ctx);
+ assert_eq!(create.universe.text, "7", "the first character plays the universe given");
+ typing(&mut create, &mut ctx, "Ysolde Marr");
+ press(&mut create, &mut ctx, KeyCode::Down);
+ press(&mut create, &mut ctx, KeyCode::Right);
+ press(&mut create, &mut ctx, KeyCode::Down);
+ typing(&mut create, &mut ctx, "the Long Patience");
+ assert!(screen(&create, &ctx).contains("the the Long Patience"));
+ press(&mut create, &mut ctx, KeyCode::Enter);
+ press(&mut create, &mut ctx, KeyCode::Enter);
+ let Transition::Replace(scenes) = press(&mut create, &mut ctx, KeyCode::Enter) else {
+ panic!("begin starts the game");
+ };
+ assert_eq!(scenes.len(), 2, "aboard your ship, landed");
+ // One step on from the people of the system the universe began in.
+ let began = ctx.galaxy.sites[ctx.galaxy.home].culture;
+ let origin = Culture::ALL[(Culture::ALL.iter().position(|&c| c == began).unwrap() + 1) % 4];
+ assert_eq!(
+ ctx.character,
+ Character { name: "Ysolde Marr".into(), origin, ship: "Long Patience".into() }
+ );
+ assert_eq!(ctx.star, ctx.galaxy.seat(origin), "you start at your own people's seat");
+ assert_eq!(ctx.system.culture, origin);
+ assert_eq!(ctx.visited.iter().copied().collect::<Vec<_>>(), vec![ctx.star]);
+ assert_eq!((ctx.seed, ctx.landed, ctx.location), (7, true, ctx.system.home_place()));
+ assert_eq!(ctx.slot, Some(dir.join("ysolde-marr-0000000000000007.json")));
+ ctx.autosave();
+ assert!(ctx.slot.as_ref().unwrap().exists());
+
+ // The same name in the same universe would overwrite them.
+ let mut again = Create::new(&ctx);
+ again.universe.suggest("7".into());
+ typing(&mut again, &mut ctx, "Ysolde Marr");
+ again.row = Row::Begin;
+ assert!(matches!(press(&mut again, &mut ctx, KeyCode::Enter), Transition::None));
+ assert!(screen(&again, &ctx).contains("There's already a Ysolde Marr"));
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn suggestions_step_back_and_forth_and_follow_where_youre_from() {
+ let mut ctx = ctx();
+ ctx.slot = Some("playing.json".into());
+ let mut create = Create::new(&ctx);
+ assert_ne!(create.universe.text, "7", "later characters get a universe of their own");
+ let first = create.name.text.clone();
+ press(&mut create, &mut ctx, KeyCode::Right);
+ assert_ne!(create.name.text, first);
+ press(&mut create, &mut ctx, KeyCode::Left);
+ assert_eq!(create.name.text, first);
+
+ create.row = Row::Ship;
+ typing(&mut create, &mut ctx, "Kettle");
+ create.row = Row::Origin;
+ press(&mut create, &mut ctx, KeyCode::Right);
+ assert_ne!(create.name.text, first, "a suggested name changes with your people");
+ assert_eq!(create.ship.text, "Kettle", "but not one you typed");
+
+ create.row = Row::Universe;
+ typing(&mut create, &mut ctx, "12x3");
+ assert_eq!(create.universe.text, "123");
+ assert!(create.system.contains("system"));
+ }
+
+ #[test]
+ fn a_character_needs_names() {
+ let mut ctx = ctx();
+ let mut create = Create::new(&ctx);
+ let clear = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
+ create.key(clear, &mut ctx);
+ create.row = Row::Begin;
+ assert!(matches!(press(&mut create, &mut ctx, KeyCode::Enter), Transition::None));
+ assert_eq!(create.row, Row::Name);
+ assert!(screen(&create, &ctx).contains("You'll need a name."));
+ typing(&mut create, &mut ctx, "Oru");
+ create.row = Row::Ship;
+ create.key(clear, &mut ctx);
+ typing(&mut create, &mut ctx, "The ");
+ create.row = Row::Begin;
+ press(&mut create, &mut ctx, KeyCode::Enter);
+ assert_eq!(create.row, Row::Ship, "\"the\" alone isn't a name");
+ }
+
+ #[test]
+ fn the_sheet_shows_what_youve_done_and_renames_your_ship() {
+ let mut ctx = ctx();
+ ctx.record.boarded = 4;
+ ctx.record.slain.insert("hollow crewman".into(), 3);
+ ctx.record.slain.insert("bulkhead sentry".into(), 2);
+ ctx.bestiary.insert(("rust-mite", Dmg::Blunt));
+ let mut sheet = Sheet::new();
+ let text = screen(&sheet, &ctx);
+ assert!(text.contains(&ctx.character.name));
+ assert!(text.contains("3 hollow crewmen") && text.contains("2 bulkhead sentries"));
+ assert!(text.contains("rust-mite") && text.contains("blunt"));
+
+ press(&mut sheet, &mut ctx, KeyCode::Char('r'));
+ sheet.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), &mut ctx);
+ typing(&mut sheet, &mut ctx, "Second Wind");
+ assert!(matches!(press(&mut sheet, &mut ctx, KeyCode::Enter), Transition::None));
+ assert_eq!(ctx.character.ship_name(), "the Second Wind");
+ assert!(matches!(press(&mut sheet, &mut ctx, KeyCode::Esc), Transition::Pop));
+ }
+
+ #[test]
+ fn plurals() {
+ assert_eq!(plural("rust-mite"), "rust-mites");
+ assert_eq!(plural("hollow crewman"), "hollow crewmen");
+ assert_eq!(plural("bulkhead sentry"), "bulkhead sentries");
+ }
+}
diff --git a/src/scenes/derelict.rs b/src/scenes/derelict.rs
index fc18f50..5b705a4 100644
--- a/src/scenes/derelict.rs
+++ b/src/scenes/derelict.rs
@@ -1,5 +1,8 @@
//! Aboard a derelict: the map, your status, and the log. The rules live in
-//! `dungeon::crawl`; this turns keys into actions and draws the result.
+//! `dungeon::crawl`; this turns keys into actions and draws the result,
+//! including the line of fire while you aim, and shots in flight. `i` opens
+//! the inventory (`scenes::inventory`), with what's on the floor beside it,
+//! and `c` your character sheet, with what you carry here.
use ratatui::Frame;
use ratatui::buffer::Buffer;
@@ -9,14 +12,28 @@ use ratatui::style::{Color, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap};
+use super::character::Sheet;
+use super::inventory::{self, Beside, Command, Inventory, Reply, View};
+use super::keys::{self, direction};
+use super::pause::{Choice, Pause};
use super::{Scene, Transition};
use crate::app::Ctx;
-use crate::dungeon::crawl::{Action, Crawl, Item, Outcome, SIGHT_RADIUS};
+use crate::dungeon::arms::{Dmg, Weapon};
+use crate::dungeon::crawl::{Action, Affinity, Crawl, Monster, Outcome, SIGHT_RADIUS, Trace};
use crate::dungeon::{Pos, Tile};
+use crate::items::{self, Gear, Item, Stack};
use crate::seed;
const PANEL_WIDTH: u16 = 34;
const LOG_HEIGHT: u16 = 7;
+/// How fast shots cross the screen: cells for every two ticks.
+const SHOT_SPEED: u64 = 3;
+/// How long a shot's impact shows, in cells' worth of flight.
+const IMPACT: u64 = 6;
+/// Most monsters listed as in view.
+const IN_VIEW: usize = 6;
+/// The most steps a run takes.
+const RUN_LIMIT: usize = 80;
type Rgb = (u8, u8, u8);
/// Cells you've seen but can't see now.
@@ -24,18 +41,113 @@ const REMEMBERED: Rgb = (64, 68, 84);
/// The hull's outline, seen from outside as you docked.
const SILHOUETTE: Rgb = (40, 44, 60);
const STAR: Rgb = (72, 72, 92);
+const LINE_OF_FIRE: Color = Color::Rgb(46, 40, 20);
+const RETICLE: Color = Color::Rgb(160, 128, 40);
+
+#[derive(Clone, Copy)]
+enum Mode {
+ Explore,
+ /// Picking where to fire.
+ Aim(Pos),
+ /// Asking whether to leave by the emergency hatch.
+ Abandon,
+}
pub struct Derelict {
crawl: Crawl,
- /// Asking whether to leave by the emergency hatch.
- abandoning: bool,
+ mode: Mode,
+ /// Open over the map, when you're looking through what you carry.
+ inventory: Option<Inventory>,
+ /// What you came aboard with, to tell what you found.
+ brought: Gear,
/// Once the crawl is over: a title and lines for the closing summary.
ending: Option<(&'static str, Vec<String>)>,
+ /// The tick this turn's shots set off, for drawing them in flight.
+ volley_at: u64,
+ /// The menu `esc` opens.
+ pause: Option<Pause>,
}
impl Derelict {
pub fn new(seed: u64) -> Self {
- Derelict { crawl: Crawl::new(seed), abandoning: false, ending: None }
+ Derelict {
+ crawl: Crawl::new(seed),
+ mode: Mode::Explore,
+ inventory: None,
+ brought: Gear::default(),
+ ending: None,
+ volley_at: 0,
+ pause: None,
+ }
+ }
+
+ /// Back aboard the wreck a save left you in, just as you left it: what
+ /// you carried, what you'd seen, what's still hunting you.
+ pub fn resumed(ctx: &Ctx) -> Option<Derelict> {
+ let mut crawl = Crawl::resume(ctx.crawl.as_ref()?)?;
+ crawl.known.clone_from(&ctx.bestiary);
+ crawl.say(format!("You're back aboard {}, where you left off.", crawl.name));
+ let mut brought = ctx.hold.gear.clone();
+ brought.reload_all();
+ Some(Derelict {
+ crawl,
+ mode: Mode::Explore,
+ inventory: None,
+ brought,
+ ending: None,
+ volley_at: 0,
+ pause: None,
+ })
+ }
+
+ /// Boards a derelict with everything you carry. Whatever's left of it
+ /// comes back with you if you get out; your own torch and rivet gun come
+ /// back whatever happens. What's in your ship's lockers stays safe
+ /// aboard. What you know about what lives aboard wrecks, you know here
+ /// too.
+ ///
+ /// The hold isn't touched until the crawl ends. The crawl itself is
+ /// kept in `ctx.crawl` after every move, so a game saved halfway
+ /// through (or quit) comes back aboard the wreck.
+ pub fn board(seed: u64, ctx: &mut Ctx) -> Self {
+ let mut derelict = Derelict::new(seed);
+ ctx.record.boarded += 1;
+ let c = &mut derelict.crawl;
+ c.gear = ctx.hold.gear.clone();
+ c.gear.reload_all();
+ c.known.clone_from(&ctx.bestiary);
+ derelict.brought = c.gear.clone();
+ let mut brought: Vec<String> =
+ everything(&c.gear).iter().filter(|s| !s.item.is_kit()).map(Stack::name).collect();
+ if !brought.is_empty() {
+ if brought.len() > 4 {
+ brought.truncate(3);
+ brought.push("more".into());
+ }
+ let brought = listed(&brought);
+ c.say(format!("You've brought {brought}."));
+ }
+ ctx.crawl = Some(derelict.crawl.saved());
+ derelict
+ }
+
+ /// Takes a turn (or doesn't), and keeps up with what came of it.
+ fn act(&mut self, action: Action, ctx: &mut Ctx) {
+ self.crawl.act(action);
+ if !self.crawl.volley.is_empty() {
+ self.volley_at = ctx.ticks;
+ }
+ ctx.bestiary.extend(self.crawl.known.iter().copied());
+ for kind in self.crawl.slain.drain(..) {
+ *ctx.record.slain.entry(kind.to_string()).or_default() += 1;
+ }
+ ctx.crawl = self.crawl.outcome.is_none().then(|| self.crawl.saved());
+ if let Some(outcome) = self.crawl.outcome
+ && self.ending.is_none()
+ {
+ self.mode = Mode::Explore;
+ self.finish(outcome, ctx);
+ }
}
/// Called once, when the crawl ends: banks what you carried if you got
@@ -44,47 +156,190 @@ impl Derelict {
let c = &self.crawl;
let lines = match outcome {
Outcome::Escaped => {
- ctx.hold.artifacts.extend(c.artifacts.iter().cloned());
- ctx.hold.scrip += c.scrip;
- let mut lines = vec![
- format!("You cast off from {}.", c.name),
- String::new(),
- format!(
- "Recovered {} and {} ¤ of salvage.",
- count(c.artifacts.len(), "artifact"),
- c.scrip
- ),
- ];
- lines.extend(c.artifacts.iter().map(|a| format!(" * {}", a.unidentified)));
+ ctx.record.escaped += 1;
+ ctx.hold.gear = c.gear.clone();
+ let found = gained(&self.brought, &c.gear);
+ let mut lines = vec![format!("You cast off from {}.", c.name), String::new()];
+ if found.is_empty() {
+ lines.push("You come away with nothing new.".into());
+ } else {
+ lines.push("You come away with:".into());
+ lines.extend(found.iter().map(|s| {
+ let (glyph, _) = inventory::look(&s.item);
+ format!(" {glyph} {}", s.name())
+ }));
+ }
+ let worth: u32 = found
+ .iter()
+ .map(|s| match &s.item {
+ Item::Salvage(v) => v.value * s.count,
+ _ => 0,
+ })
+ .sum();
+ if worth > 0 {
+ lines.push(format!("The salvage is worth about {worth} ¤ to a broker."));
+ }
lines.push(String::new());
lines.push(format!(
- "Your hold now carries {} and {} ¤.",
- count(ctx.hold.artifacts.len(), "artifact"),
- ctx.hold.scrip
+ "You carry {} of {} kg. Stow what you don't need in the lockers [i].",
+ items::kg(c.gear.carried()),
+ items::kg(c.gear.capacity)
));
lines
}
- Outcome::Died { killer } => vec![
- format!("The {killer} brings you down aboard {}.", c.name),
- "Your crew drags you back to your ship, but".into(),
- "everything you carried stays with the wreck.".into(),
- ],
+ Outcome::Died { killer } => {
+ ctx.record.downed += 1;
+ ctx.hold.lose_carried(&c.gear);
+ vec![
+ format!("The {killer} brings you down aboard {}.", c.name),
+ format!("Your crew drags you back to {}, but", ctx.character.ship_name()),
+ "everything you carried stays with the wreck.".into(),
+ ]
+ }
};
let title = match outcome {
Outcome::Escaped => " SAFE ABOARD YOUR SHIP ",
Outcome::Died { .. } => " PULLED BACK FROM THE EDGE ",
};
self.ending = Some((title, lines));
+ // You may well be holding the key that walked you into the
+ // airlock: its repeats mustn't wave the summary away unread.
+ ctx.settle();
+ }
+
+ /// Shift and a direction: steps that way until there's something to
+ /// stop for: something in sight, a wound, something said or underfoot,
+ /// a doorway, another room, a closed door or the airlock ahead. With
+ /// something in sight already, it's just a step.
+ fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) {
+ for step in 0..RUN_LIMIT {
+ let c = &self.crawl;
+ // Opening a door and leaving are steps you take, not ones a
+ // run carries you into.
+ let ahead = c.map.tile(c.player.offset(dx, dy));
+ if step > 0 && matches!(ahead, Tile::Airlock | Tile::Door { open: false }) {
+ break;
+ }
+ let (from, hp, said) = (c.player, c.hp, c.said);
+ let room = c.map.room_index(c.player);
+ self.act(Action::Move(dx, dy), ctx);
+ let c = &self.crawl;
+ let stop = c.outcome.is_some()
+ || c.player == from
+ || c.hp < hp
+ || c.said != said
+ || c.monsters.iter().any(|m| c.fov.contains(m.pos))
+ || !c.here().is_empty()
+ || matches!(c.map.tile(c.player), Tile::Door { .. })
+ // Out of a doorway, the first step is into the room.
+ || (step > 0 && c.map.room_index(c.player) != room);
+ if stop {
+ break;
+ }
+ }
}
- fn render_map(&self, buf: &mut Buffer, area: Rect) {
+ fn explore(&mut self, code: KeyCode) -> Option<Action> {
+ if let Some((dx, dy)) = direction(code) {
+ return Some(Action::Move(dx, dy));
+ }
+ match code {
+ KeyCode::Char('.' | '5') => Some(Action::Wait),
+ KeyCode::Char('a') => Some(Action::UseMedpatch),
+ KeyCode::Char('r') => Some(Action::Reload),
+ KeyCode::Char('g' | ',') if self.crawl.here().len() > 1 => {
+ self.inventory = Some(Inventory::beside());
+ None
+ }
+ KeyCode::Char('g' | ',') => Some(Action::PickUp),
+ KeyCode::Char('f') if self.crawl.gear.gun.loaded == 0 => Some(Action::Reload),
+ KeyCode::Char('f') => {
+ let at = self.targets().first().copied().unwrap_or(self.crawl.player);
+ self.mode = Mode::Aim(at);
+ None
+ }
+ KeyCode::Char('i') => {
+ self.inventory = Some(Inventory::new());
+ None
+ }
+ KeyCode::Esc => {
+ let note = format!(
+ "Aboard {}. Quit, and you'll come back aboard just as you left it.",
+ self.crawl.name
+ );
+ self.pause = Some(Pause::new(Some("Abandon ship"), Some(note)));
+ None
+ }
+ _ => None,
+ }
+ }
+
+ fn aim(&mut self, code: KeyCode, at: Pos) -> Option<Action> {
+ if let Some((dx, dy)) = direction(code) {
+ let to = at.offset(dx, dy);
+ if self.crawl.map.contains(to) {
+ self.mode = Mode::Aim(to);
+ }
+ return None;
+ }
+ match code {
+ KeyCode::Tab | KeyCode::Char('+' | '=') => self.mode = Mode::Aim(self.cycle(at, 1)),
+ KeyCode::BackTab | KeyCode::Char('-') => self.mode = Mode::Aim(self.cycle(at, -1)),
+ KeyCode::Enter | KeyCode::Char('f' | 't') if at != self.crawl.player => {
+ self.mode = Mode::Explore;
+ return Some(Action::Fire(at));
+ }
+ KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Explore,
+ _ => {}
+ }
+ None
+ }
+
+ /// Where the things you can see are, nearest first.
+ fn targets(&self) -> Vec<Pos> {
+ let c = &self.crawl;
+ let mut targets: Vec<Pos> =
+ c.monsters.iter().map(|m| m.pos).filter(|&p| c.fov.contains(p)).collect();
+ targets.sort_by_key(|&p| (distance2(c.player, p), p.y, p.x));
+ targets
+ }
+
+ /// The next target after (or before) the one at `at`.
+ fn cycle(&self, at: Pos, by: isize) -> Pos {
+ let targets = self.targets();
+ match targets.iter().position(|&p| p == at) {
+ _ if targets.is_empty() => at,
+ Some(i) => targets[(i as isize + by).rem_euclid(targets.len() as isize) as usize],
+ None => targets[0],
+ }
+ }
+
+ fn render_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) {
let c = &self.crawl;
let left = camera(c.player.x, c.map.width, i32::from(area.width));
let top = camera(c.player.y, c.map.height, i32::from(area.height));
+ let flying = self.in_flight(ticks);
+ let (aim, line) = match self.mode {
+ Mode::Aim(at) => (Some(at), c.line_of_fire(at)),
+ _ => (None, Vec::new()),
+ };
for sy in 0..area.height {
for sx in 0..area.width {
let p = Pos::new(left + i32::from(sx), top + i32::from(sy));
- let (glyph, style) = self.look(p);
+ let (mut glyph, mut style) = self.look(p);
+ if line.contains(&p) {
+ style = style.bg(LINE_OF_FIRE);
+ }
+ if aim == Some(p) {
+ style = style.bg(RETICLE);
+ if p != c.player && c.monster_at(p).is_none() {
+ (glyph, style) = ('×', style.fg(Color::Black));
+ }
+ }
+ if let Some(&(_, g, colour)) = flying.iter().find(|(q, ..)| *q == p) {
+ glyph = g;
+ style = style.fg(rgb(colour, 1.0)).bold();
+ }
if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) {
cell.set_char(glyph).set_style(style);
}
@@ -92,6 +347,29 @@ impl Derelict {
}
}
+ /// What of this turn's shots is on screen `ticks` in: each round's head
+ /// and a fading tail while it flies, then a flash where it stops.
+ fn in_flight(&self, ticks: u64) -> Vec<(Pos, char, Rgb)> {
+ let c = &self.crawl;
+ let flown = ticks.saturating_sub(self.volley_at) * SHOT_SPEED / 2;
+ let mut out = Vec::new();
+ for trace in &c.volley {
+ let Some(flown) = flown.checked_sub(u64::from(trace.delay)) else { continue };
+ let (len, colour) = (trace.cells.len() as u64, trace.dmg.rgb());
+ let Some(&end) = trace.cells.last() else { continue };
+ if flown < len {
+ out.push((trace.cells[flown as usize], shot_glyph(trace), colour));
+ if flown > 0 {
+ out.push((trace.cells[flown as usize - 1], '·', dim(colour, 0.6)));
+ }
+ } else if flown < len + IMPACT {
+ out.push((end, if trace.hit { '*' } else { '∙' }, colour));
+ }
+ }
+ out.retain(|(p, ..)| c.fov.contains(*p));
+ out
+ }
+
/// How the cell at `p` looks from where you stand: lit by your lamp if
/// you can see it, from memory if you've seen it, and otherwise only the
/// hull's outline against the stars.
@@ -105,10 +383,10 @@ impl Derelict {
if let Some(m) = c.monster_at(p) {
return (m.kind.glyph, Style::new().fg(rgb(m.kind.rgb, 1.0)).bold());
}
- let dist = f64::from((p.x - c.player.x).pow(2) + (p.y - c.player.y).pow(2)).sqrt();
+ let dist = f64::from(distance2(p, c.player)).sqrt();
let light = 1.0 - 0.6 * (dist / f64::from(SIGHT_RADIUS)).min(1.0);
if let Some(loot) = c.loot_at(p) {
- let (glyph, colour) = item_look(&loot.item);
+ let (glyph, colour) = inventory::look(&loot.stack.item);
return (glyph, Style::new().fg(rgb(colour, light.max(0.8))).bold());
}
let (glyph, colour) = tile_look(tile);
@@ -116,7 +394,7 @@ impl Derelict {
}
if c.seen(p) {
if let Some(loot) = c.loot_at(p) {
- let (glyph, colour) = item_look(&loot.item);
+ let (glyph, colour) = inventory::look(&loot.stack.item);
return (glyph, Style::new().fg(rgb(colour, 0.45)));
}
return (tile_look(tile).0, Style::new().fg(rgb(REMEMBERED, 1.0)));
@@ -138,45 +416,213 @@ impl Derelict {
.padding(Padding::horizontal(1));
let inner = block.inner(area);
frame.render_widget(block, area);
- let [info, help] =
- Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(inner);
+ let help = self.help(inner.width);
+ let [info, keys] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(help.len() as u16)])
+ .areas(inner);
- let label = |text: &'static str| Span::from(text).dark_gray();
let mut lines = vec![
Line::from(c.name.as_str()).bold().fg(Color::Yellow),
Line::from(c.description.as_str()).italic().dark_gray(),
Line::default(),
Line::from(vec![label("health "), health_bar(c.hp, c.max_hp, 12)]),
- Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.medpatches))]),
+ ];
+ if c.bleeding > 0 {
+ lines.push(Line::from(vec![
+ label(" "),
+ format!("bleeding {}", c.bleeding).red(),
+ ]));
+ }
+ if c.burning > 0 {
+ lines.push(Line::from(vec![
+ label(" "),
+ Span::from(format!("alight {}", c.burning)).fg(rgb(Dmg::Burn.rgb(), 1.0)),
+ ]));
+ }
+ lines.extend([
+ Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.gear.medpatches()))]),
Line::from(vec![label("here "), Span::from(c.location())]),
Line::from(vec![label("turn "), Span::from(c.turns.to_string())]),
Line::default(),
- Line::from(vec![label("salvage "), Span::from(format!("{} ¤", c.scrip))]),
- Line::from(vec![label("artifacts "), Span::from(c.artifacts.len().to_string())]),
- ];
- for artifact in &c.artifacts {
- lines.push(Line::from(format!("* {}", artifact.unidentified)).fg(Color::Yellow));
+ ]);
+
+ let (blade, gun) = (&c.gear.melee, &c.gear.gun);
+ let blade_stats = format!("{} · {}%", blade.damage_text(), percent(blade.accuracy));
+ lines.extend([
+ Line::from(vec![label("melee "), title(blade, 20)]),
+ Line::from(vec![label(" "), blade_stats.dark_gray()]),
+ Line::from(vec![label("ranged "), title(gun, 20)]),
+ Line::from(vec![
+ label(" "),
+ magazine(gun),
+ format!(" {}", gun.damage_text()).dark_gray(),
+ ]),
+ Line::from(vec![
+ label("carrying "),
+ Span::from(format!(
+ "{} / {} kg",
+ items::kg(c.gear.carried()),
+ items::kg(c.gear.capacity)
+ )),
+ ]),
+ Line::default(),
+ ]);
+
+ match self.mode {
+ Mode::Aim(at) => self.target_lines(at, &mut lines),
+ _ => self.in_view_lines(&mut lines),
+ }
+
+ let found = gained(&self.brought, &c.gear);
+ if !found.is_empty() {
+ lines.push(Line::from("found").dark_gray());
+ }
+ for stack in &found {
+ let (glyph, colour) = inventory::look(&stack.item);
+ lines.push(Line::from(vec![
+ Span::from(format!("{glyph} ")).fg(rgb(colour, 1.0)),
+ Span::from(stack.name()),
+ ]));
}
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info);
+ frame.render_widget(Paragraph::new(help), keys);
+ }
- // Pairs of (key, what it does) on one line.
- let keys = |pairs: &[(&'static str, &'static str)]| {
- Line::from(
- pairs
- .iter()
- .flat_map(|&(k, what)| [Span::from(k).bold(), Span::from(what).dark_gray()])
- .collect::<Vec<_>>(),
- )
+ /// The monsters you can see, nearest first, with how hurt they are.
+ fn in_view_lines<'a>(&'a self, lines: &mut Vec<Line<'a>>) {
+ let c = &self.crawl;
+ let mut seen: Vec<&Monster> = c.monsters.iter().filter(|m| c.fov.contains(m.pos)).collect();
+ if seen.is_empty() {
+ return;
+ }
+ seen.sort_by_key(|m| distance2(c.player, m.pos));
+ lines.push(Line::from("in view").dark_gray());
+ for m in seen.iter().take(IN_VIEW) {
+ let mut spans = vec![
+ Span::styled(
+ m.kind.glyph.to_string(),
+ Style::new().fg(rgb(m.kind.rgb, 1.0)).bold(),
+ ),
+ Span::from(format!(" {:<18}", m.kind.name)),
+ mini_bar(m.hp, m.kind.hp, 6),
+ ];
+ if m.bleeding > 0 {
+ spans.push("•".red());
+ }
+ if m.burning > 0 {
+ spans.push(Span::from("*").fg(rgb(Dmg::Burn.rgb(), 1.0)));
+ }
+ if m.stunned > 0 {
+ spans.push("z".yellow());
+ }
+ lines.push(Line::from(spans));
+ }
+ if seen.len() > IN_VIEW {
+ lines.push(Line::from(format!(" and {} more", seen.len() - IN_VIEW)).dark_gray());
+ }
+ lines.push(Line::default());
+ }
+
+ /// What's under the reticle: how hurt it is, the odds, and what you
+ /// know of how it takes each kind of damage.
+ fn target_lines<'a>(&'a self, at: Pos, lines: &mut Vec<Line<'a>>) {
+ let c = &self.crawl;
+ if at == c.player {
+ lines.push(Line::from("Nothing in sight to shoot.").dark_gray());
+ lines.push(Line::from("Aim with the move keys.").dark_gray());
+ lines.push(Line::default());
+ return;
+ }
+ let away = f64::from(distance2(c.player, at)).sqrt().round();
+ let away =
+ if away == 1.0 { "1 cell away".to_string() } else { format!("{away} cells away") };
+ let Some(m) = c.monster_at(at).filter(|m| c.fov.contains(m.pos)) else {
+ lines.push(Line::from(vec![label("target "), Span::from("nothing there")]));
+ let range = if c.in_range(at) { "" } else { " · out of range" };
+ lines.push(Line::from(format!("{away}{range}")).dark_gray());
+ lines.push(Line::default());
+ return;
};
- frame.render_widget(
- Paragraph::new(vec![
- keys(&[("arrows hjkl yubn", " move")]),
- keys(&[(".", " wait "), ("m", " medpatch")]),
- keys(&[("walk into Θ", " leave with loot")]),
- keys(&[("esc", " abandon ship, loot and all")]),
- ]),
- help,
- );
+ let kind = m.kind;
+ lines.push(Line::from(vec![
+ label("target "),
+ Span::styled(kind.name, Style::new().fg(rgb(kind.rgb, 1.0)).bold()),
+ ]));
+ let mut state = vec![mini_bar(m.hp, kind.hp, 12)];
+ if m.bleeding > 0 {
+ state.push(format!(" bleeding {}", m.bleeding).red());
+ }
+ if m.burning > 0 {
+ state.push(Span::from(" burning").fg(rgb(Dmg::Burn.rgb(), 1.0)));
+ }
+ if m.stunned > 0 {
+ state.push(" stunned".yellow());
+ }
+ lines.push(Line::from(state));
+ let odds = match c.odds(at) {
+ _ if !c.in_range(at) => "out of range".to_string(),
+ Some(p) if c.gear.gun.gun.is_some_and(|g| g.pellets > 1) => {
+ format!("{}% a pellet", percent(p))
+ }
+ Some(p) => format!("{}% to hit", percent(p)),
+ None => String::new(),
+ };
+ lines.push(Line::from(format!("{away} · {odds}")).dark_gray());
+
+ let mut known: [(Affinity, Vec<&str>); 4] = [
+ (Affinity::Weak, Vec::new()),
+ (Affinity::Resists, Vec::new()),
+ (Affinity::Immune, Vec::new()),
+ (Affinity::Normal, Vec::new()),
+ ];
+ let mut untried = Vec::new();
+ for dmg in Dmg::ALL {
+ match c.knows(kind, dmg) {
+ Some(a) => known.iter_mut().find(|(k, _)| *k == a).unwrap().1.push(dmg.name()),
+ None => untried.push(dmg.name()),
+ }
+ }
+ for (affinity, dmgs) in known {
+ if dmgs.is_empty() {
+ continue;
+ }
+ let (word, colour) = match affinity {
+ Affinity::Weak => ("weak to ", Color::Green),
+ Affinity::Resists => ("resists ", Color::Yellow),
+ Affinity::Immune => ("immune to ", Color::Red),
+ Affinity::Normal => ("no matter: ", Color::Gray),
+ };
+ lines.push(Line::from(vec![Span::from(word).fg(colour), Span::from(dmgs.join(", "))]));
+ }
+ if !untried.is_empty() {
+ lines.push(Line::from(format!("untried: {}", untried.join(", "))).dark_gray());
+ }
+ lines.push(Line::default());
+ }
+
+ fn help(&self, width: u16) -> Vec<Line<'static>> {
+ let hints: &[(&str, &str)] = match self.mode {
+ Mode::Aim(_) => &[
+ ("move keys", "aim"),
+ ("tab", "next target"),
+ ("f enter", "fire"),
+ ("esc", "lower it"),
+ ],
+ _ => &[
+ ("arrows hjkl yubn", "move, attack"),
+ ("shift", "run"),
+ ("f", "fire"),
+ ("r", "reload"),
+ (".", "wait"),
+ ("a", "medpatch"),
+ ("g", "pick up"),
+ ("i", "inventory"),
+ ("c", "character"),
+ ("into Θ", "leave with loot"),
+ ("esc", "menu: quit or abandon ship"),
+ ],
+ };
+ keys::packed(hints, width)
}
fn render_log(&self, frame: &mut Frame, area: Rect) {
@@ -201,75 +647,214 @@ impl Derelict {
}
}
+/// Everything on you, as stacks: worn, ready and packed.
+fn everything(gear: &Gear) -> Vec<Stack> {
+ let worn = gear.worn.values().map(|a| Stack::one(Item::Armor(a.clone())));
+ let ready = [&gear.melee, &gear.gun].into_iter().map(|w| Stack::one(Item::Weapon(w.clone())));
+ worn.chain(ready).chain(gear.pack.iter().cloned()).collect()
+}
+
+/// What you have now that you didn't come aboard with.
+fn gained(before: &Gear, after: &Gear) -> Vec<Stack> {
+ // A gun you've fired is still the gun you brought.
+ let unloaded = |mut s: Stack| {
+ if let Item::Weapon(w) = &mut s.item {
+ w.loaded = 0;
+ }
+ s
+ };
+ let mut had: Vec<Stack> = everything(before).into_iter().map(unloaded).collect();
+ let mut found = Vec::new();
+ for stack in everything(after).into_iter().map(unloaded) {
+ let mut count = stack.count;
+ if let Some(old) = had.iter_mut().find(|s| s.item == stack.item) {
+ let same = old.count.min(count);
+ old.count -= same;
+ count -= same;
+ }
+ if count > 0 {
+ items::stow(&mut found, Stack { count, ..stack });
+ }
+ }
+ found
+}
+
impl Scene for Derelict {
fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
if self.ending.is_some() {
return Transition::Pop;
}
- if self.abandoning {
- self.abandoning = false;
- return match key.code {
- KeyCode::Char('y' | 'Y') => Transition::Pop,
- _ => Transition::None,
+ if let Some(pause) = &mut self.pause {
+ return match pause.key(key) {
+ None => Transition::None,
+ Some(choice) => {
+ self.pause = None;
+ match choice {
+ Choice::Quit => Transition::Title,
+ Choice::Here => {
+ self.mode = Mode::Abandon;
+ Transition::None
+ }
+ Choice::Resume => Transition::None,
+ }
+ }
};
}
- // Vi keys, arrows, and the number pad with or without num lock.
- let action = match key.code {
- KeyCode::Left | KeyCode::Char('h' | '4') => Action::Move(-1, 0),
- KeyCode::Right | KeyCode::Char('l' | '6') => Action::Move(1, 0),
- KeyCode::Up | KeyCode::Char('k' | '8') => Action::Move(0, -1),
- KeyCode::Down | KeyCode::Char('j' | '2') => Action::Move(0, 1),
- KeyCode::Home | KeyCode::Char('y' | '7') => Action::Move(-1, -1),
- KeyCode::PageUp | KeyCode::Char('u' | '9') => Action::Move(1, -1),
- KeyCode::End | KeyCode::Char('b' | '1') => Action::Move(-1, 1),
- KeyCode::PageDown | KeyCode::Char('n' | '3') => Action::Move(1, 1),
- KeyCode::Char('.' | '5') => Action::Wait,
- KeyCode::Char('m') => Action::UseMedpatch,
- KeyCode::Esc => {
- self.abandoning = true;
- return Transition::None;
+ if let Some(inv) = &mut self.inventory {
+ let here: Vec<Stack> =
+ self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect();
+ let view = View {
+ gear: &self.crawl.gear,
+ beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])),
+ known: &self.crawl.known,
+ hurt: true,
+ shop: None,
+ sort: ctx.inventory_sort,
+ };
+ let action = match inv.key(key, &view) {
+ Reply::Stay => return Transition::None,
+ Reply::Close => {
+ self.inventory = None;
+ return Transition::None;
+ }
+ Reply::Sort(sort) => {
+ ctx.inventory_sort = sort;
+ return Transition::None;
+ }
+ Reply::Do(command) => match command {
+ Command::Ready(i) => Action::Ready(i),
+ Command::Wear(i) => Action::Wear(i),
+ Command::TakeOff(slot) => Action::TakeOff(slot),
+ Command::Eat(i) => Action::Eat(i),
+ Command::UseMedpatch => Action::UseMedpatch,
+ Command::Drop(spot, n) | Command::Stow(spot, n) => Action::Drop(spot, n),
+ Command::Fetch(i, n) => Action::Take(i, n),
+ // There's no one to trade with aboard a wreck.
+ Command::Buy(_)
+ | Command::Sell(_)
+ | Command::SellAboard(_)
+ | Command::Mend(_)
+ | Command::MendAboard(_)
+ | Command::MendAll => {
+ return Transition::None;
+ }
+ },
+ };
+ let turns = self.crawl.turns;
+ self.act(action, ctx);
+ // Anything that took a turn closes it, so you see what happened.
+ match &mut self.inventory {
+ Some(_) if self.crawl.turns != turns || self.ending.is_some() => {
+ self.inventory = None;
+ }
+ Some(inv) => inv.note = self.crawl.log.last().cloned(),
+ None => {}
+ }
+ return Transition::None;
+ }
+ let action = match self.mode {
+ Mode::Abandon => {
+ self.mode = Mode::Explore;
+ return match key.code {
+ KeyCode::Char('y' | 'Y') => {
+ ctx.record.abandoned += 1;
+ ctx.hold.lose_carried(&self.crawl.gear);
+ ctx.crawl = None;
+ Transition::Pop
+ }
+ _ => Transition::None,
+ };
}
- _ => return Transition::None,
+ Mode::Explore => match keys::movement(key) {
+ Some((dx, dy, true)) => {
+ self.run(dx, dy, ctx);
+ return Transition::None;
+ }
+ _ if matches!(key.code, KeyCode::Char('c' | 'C')) => {
+ let gear = self.crawl.gear.clone();
+ return Transition::Push(Box::new(Sheet::aboard(&self.crawl.name, gear)));
+ }
+ _ => self.explore(key.code),
+ },
+ Mode::Aim(at) => self.aim(key.code, at),
};
- self.crawl.act(action);
- if let Some(outcome) = self.crawl.outcome {
- self.finish(outcome, ctx);
+ if let Some(action) = action {
+ self.act(action, ctx);
}
Transition::None
}
- fn render(&self, frame: &mut Frame, _ctx: &Ctx) {
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
let [main, panel] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
.areas(frame.area());
let [map, log] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main);
- self.render_map(frame.buffer_mut(), map);
+ self.render_map(frame.buffer_mut(), map, ctx.ticks);
self.render_log(frame, log);
self.render_panel(frame, panel);
- if self.abandoning {
- popup(
+ if let Some((title, lines)) = &self.ending {
+ let mut lines: Vec<Line> = lines.iter().map(|l| Line::from(l.as_str())).collect();
+ lines.extend([Line::default(), keys::line(&[("any key", "carry on")])]);
+ popup(frame, title, lines);
+ return;
+ }
+ match self.mode {
+ Mode::Abandon => popup(
frame,
" ABANDON SHIP? ",
vec![
- format!("Leave {} by the emergency hatch?", self.crawl.name),
- "Whatever you're carrying stays behind.".into(),
- String::new(),
- "[y] abandon [any other key] keep exploring".into(),
+ Line::from(format!("Leave {} by the emergency hatch?", self.crawl.name)),
+ Line::from("Whatever you're carrying stays behind."),
+ Line::default(),
+ keys::line(&[("y", "abandon"), ("any other key", "keep exploring")]),
],
- );
- } else if let Some((title, lines)) = &self.ending {
- let mut lines = lines.clone();
- lines.extend([String::new(), "[any key]".into()]);
- popup(frame, title, lines);
+ ),
+ Mode::Explore | Mode::Aim(_) => {}
+ }
+ if let Some(inv) = &self.inventory {
+ let here: Vec<Stack> =
+ self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect();
+ let view = View {
+ gear: &self.crawl.gear,
+ beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])),
+ known: &self.crawl.known,
+ hurt: true,
+ shop: None,
+ sort: ctx.inventory_sort,
+ };
+ inv.render(frame, frame.area(), &view);
+ }
+ if let Some(pause) = &self.pause {
+ pause.render(frame, frame.area());
}
}
}
+/// A round in flight, pointing the way it's going. Cells are about twice as
+/// tall as they're wide, so the slope on screen is double the slope in cells.
+fn shot_glyph(trace: &Trace) -> char {
+ match trace.dmg {
+ Dmg::Burn => return '•',
+ Dmg::Shock => return '~',
+ Dmg::Hymn => return '♪',
+ _ => {}
+ }
+ let (Some(first), Some(last)) = (trace.cells.first(), trace.cells.last()) else { return '*' };
+ let (dx, dy) = (f64::from(last.x - first.x), f64::from(last.y - first.y) * 2.0);
+ let angle = dy.atan2(dx).to_degrees().rem_euclid(180.0);
+ match angle {
+ a if !(22.5..157.5).contains(&a) => '-',
+ a if a < 67.5 => '\\',
+ a if a < 112.5 => '|',
+ _ => '/',
+ }
+}
+
/// The left or top edge of the view: keeps `focus` centred, but doesn't
/// scroll past the map's edges, and centres a map smaller than the view.
-fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 {
+pub(crate) fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 {
if map_size <= view_size {
-(view_size - map_size) / 2
} else {
@@ -290,12 +875,34 @@ pub(crate) fn tile_look(tile: Tile) -> (char, Rgb) {
}
}
-pub(crate) fn item_look(item: &Item) -> (char, Rgb) {
- match item {
- Item::Artifact(_) => ('*', (255, 214, 92)),
- Item::Salvage { .. } => ('%', (150, 200, 222)),
- Item::Medpatch => ('!', (240, 96, 96)),
+/// A weapon's name in the colour of its damage, cut to `width`. Relics
+/// stand out.
+fn title(w: &Weapon, width: usize) -> Span<'static> {
+ let name: String = w.name().chars().take(width).collect();
+ let style = Style::new().fg(rgb(w.dmg.rgb(), 1.0));
+ Span::styled(name, if w.named { style.bold() } else { style })
+}
+
+/// Rounds left, as pips.
+fn magazine(gun: &Weapon) -> Span<'static> {
+ let Some(g) = gun.gun else { return Span::from("") };
+ if gun.loaded == 0 {
+ return "empty (r)".red();
}
+ let pips = "▮".repeat(gun.loaded as usize) + &"▯".repeat((g.magazine - gun.loaded) as usize);
+ Span::from(pips).white()
+}
+
+fn label(text: &'static str) -> Span<'static> {
+ Span::from(text).dark_gray()
+}
+
+fn percent(p: f64) -> i32 {
+ (p * 100.0).round() as i32
+}
+
+fn distance2(a: Pos, b: Pos) -> i32 {
+ (a.x - b.x).pow(2) + (a.y - b.y).pow(2)
}
fn rgb((r, g, b): Rgb, light: f64) -> Color {
@@ -303,6 +910,11 @@ fn rgb((r, g, b): Rgb, light: f64) -> Color {
Color::Rgb(scale(r), scale(g), scale(b))
}
+fn dim((r, g, b): Rgb, k: f64) -> Rgb {
+ let scale = |c: u8| (f64::from(c) * k).round() as u8;
+ (scale(r), scale(g), scale(b))
+}
+
/// A sprinkling of stars that stays put as the view scrolls.
fn is_star(p: Pos) -> bool {
let cell = (u64::from(p.x as u32) << 32) | u64::from(p.y as u32);
@@ -320,14 +932,32 @@ fn health_bar(hp: i32, max: i32, width: i32) -> Span<'static> {
Span::styled(format!("{bar} {hp}/{max}"), Style::new().fg(colour))
}
-fn count(n: usize, noun: &str) -> String {
- if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") }
+/// A monster's health, without the numbers: you can see how hurt it is,
+/// not count its hit points.
+fn mini_bar(hp: i32, max: i32, width: i32) -> Span<'static> {
+ let hp = hp.clamp(0, max);
+ let filled = ((hp * width + max - 1) / max).clamp(0, width);
+ let colour = match hp * 4 / max {
+ 3.. => Color::Green,
+ 2 => Color::Yellow,
+ _ => Color::Red,
+ };
+ let bar = "█".repeat(filled as usize) + &"░".repeat((width - filled) as usize);
+ Span::styled(bar, Style::new().fg(colour))
}
-fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) {
+fn listed(items: &[String]) -> String {
+ match items {
+ [] => String::new(),
+ [one] => one.clone(),
+ [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
+ }
+}
+
+fn popup(frame: &mut Frame, title: &str, lines: Vec<Line>) {
let area = frame.area();
- let widest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
- let width = (widest + 4).min(area.width);
+ let widest = lines.iter().map(Line::width).max().unwrap_or(0) as u16;
+ let width = (widest + 4).max(title.chars().count() as u16 + 4).min(area.width);
let height = (lines.len() as u16 + 2).min(area.height);
let [popup] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
let [popup] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup);
@@ -335,7 +965,6 @@ fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) {
.border_style(Style::new().fg(Color::Yellow))
.title(Line::from(title.to_string()).bold())
.padding(Padding::horizontal(1));
- let lines: Vec<Line> = lines.into_iter().map(Line::from).collect();
frame.render_widget(Clear, popup);
frame.render_widget(Paragraph::new(lines).block(block), popup);
}
@@ -347,7 +976,9 @@ mod tests {
use ratatui::crossterm::event::KeyModifiers;
use super::*;
+ use crate::dungeon::arms::{self, Source};
use crate::term::KeyboardCaps;
+ use ratatui::crossterm::event::KeyCode as Key;
fn ctx() -> Ctx {
Ctx::new(1, KeyboardCaps { key_release: false })
@@ -379,27 +1010,100 @@ mod tests {
})
}
+ fn shifted(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition {
+ scene.key(KeyEvent::new(code, KeyModifiers::SHIFT), ctx)
+ }
+
+ #[test]
+ fn running_goes_until_something_happens_but_not_out_the_airlock() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::board(3, &mut ctx);
+ let start = scene.crawl.player;
+ let toward = key_toward_airlock(&scene);
+ let KeyCode::Char(c) = toward else { unreachable!() };
+ let away = keys::direction(toward).map(|(dx, dy)| (-dx, -dy)).unwrap();
+ let away = [
+ Key::Left,
+ Key::Right,
+ Key::Up,
+ Key::Down,
+ Key::Home,
+ Key::PageUp,
+ Key::End,
+ Key::PageDown,
+ ]
+ .into_iter()
+ .find(|&k| keys::direction(k) == Some(away))
+ .unwrap();
+ shifted(&mut scene, away, &mut ctx);
+ let ran = scene.crawl.player.steps_to(start);
+ assert!(ran >= 1, "it gets going");
+ assert_eq!(scene.crawl.turns, ran as u32, "a turn a step, straight on");
+ // And back: it stops beside the airlock rather than go through.
+ for _ in 0..ran {
+ shifted(&mut scene, KeyCode::Char(c.to_ascii_uppercase()), &mut ctx);
+ if scene.crawl.player == start {
+ break;
+ }
+ }
+ assert_eq!(scene.crawl.player, start);
+ assert!(scene.crawl.outcome.is_none(), "still aboard");
+ assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None));
+ assert!(scene.crawl.outcome.is_some(), "a step of your own takes you out");
+ }
+
+ #[test]
+ fn a_medpatch_is_a_and_the_sheet_is_c() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::board(3, &mut ctx);
+ scene.crawl.gear.add(medpatches(1));
+ scene.crawl.hp -= 5;
+ press(&mut scene, KeyCode::Char('a'), &mut ctx);
+ assert_eq!(scene.crawl.gear.medpatches(), 0);
+ assert!(matches!(press(&mut scene, KeyCode::Char('c'), &mut ctx), Transition::Push(_)));
+ }
+
+ fn medpatches(n: u32) -> Stack {
+ Stack { item: Item::Medpatch, count: n }
+ }
+
+ fn salvage() -> Item {
+ Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 12, mass: 2000 })
+ }
+
#[test]
fn escaping_banks_what_you_carry() {
let mut ctx = ctx();
- let mut scene = Derelict::new(3);
- scene.crawl.scrip = 55;
+ let mut scene = Derelict::board(3, &mut ctx);
+ scene.crawl.gear.add(Stack { item: salvage(), count: 2 });
let toward = key_toward_airlock(&scene);
assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None));
- assert_eq!(ctx.hold.scrip, 55);
- assert!(screen(&scene, &ctx).contains("SAFE ABOARD YOUR SHIP"));
+ assert_eq!(ctx.hold.gear.pack.iter().filter(|s| s.item == salvage()).count(), 1);
+ let shown = screen(&scene, &ctx);
+ assert!(shown.contains("SAFE ABOARD YOUR SHIP"));
+ assert!(shown.contains("2 coils of wire") && shown.contains("24 ¤"), "{shown}");
assert!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop));
}
+ /// Esc, then the menu's second choice, then yes.
+ fn abandon(scene: &mut Derelict, ctx: &mut Ctx) {
+ press(scene, KeyCode::Esc, ctx);
+ press(scene, KeyCode::Char('2'), ctx);
+ press(scene, KeyCode::Char('y'), ctx);
+ }
+
#[test]
fn abandoning_leaves_it_behind() {
let mut ctx = ctx();
- let mut scene = Derelict::new(3);
- scene.crawl.scrip = 55;
+ let mut scene = Derelict::board(3, &mut ctx);
+ scene.crawl.gear.add(Stack::one(salvage()));
press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(screen(&scene, &ctx).contains("PAUSED"));
+ press(&mut scene, KeyCode::Char('2'), &mut ctx);
assert!(screen(&scene, &ctx).contains("ABANDON SHIP?"));
assert!(matches!(press(&mut scene, KeyCode::Char('y'), &mut ctx), Transition::Pop));
- assert_eq!(ctx.hold.scrip, 0);
+ assert!(ctx.hold.gear.pack.iter().all(|s| s.item.is_kit()));
+ assert!(ctx.crawl.is_none(), "nothing to come back to");
}
#[test]
@@ -407,7 +1111,196 @@ mod tests {
let mut ctx = ctx();
let mut scene = Derelict::new(3);
press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(matches!(press(&mut scene, KeyCode::Char('q'), &mut ctx), Transition::None));
+ assert!(!screen(&scene, &ctx).contains("PAUSED"));
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ press(&mut scene, KeyCode::Char('2'), &mut ctx);
assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None));
assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?"));
}
+
+ #[test]
+ fn saving_and_quitting_brings_you_back_where_you_were() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::board(3, &mut ctx);
+ scene.crawl.gear.add(Stack::one(salvage()));
+ let key = key_toward_airlock(&scene);
+ // A step away from the airlock and back is a turn or two in.
+ let away = match key {
+ KeyCode::Char('h') => KeyCode::Char('l'),
+ KeyCode::Char('l') => KeyCode::Char('h'),
+ KeyCode::Char('j') => KeyCode::Char('k'),
+ _ => KeyCode::Char('j'),
+ };
+ press(&mut scene, away, &mut ctx);
+ press(&mut scene, KeyCode::Char('.'), &mut ctx);
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(matches!(press(&mut scene, KeyCode::Char('3'), &mut ctx), Transition::Title));
+ let saved = ctx.crawl.clone().expect("the wreck is kept");
+ assert_eq!(saved, scene.crawl.saved());
+
+ let back = Derelict::resumed(&ctx).unwrap();
+ let (was, is) = (&scene.crawl, &back.crawl);
+ assert_eq!((is.player, is.hp, is.turns), (was.player, was.hp, was.turns));
+ assert_eq!(is.gear, was.gear);
+ assert_eq!(is.loot, was.loot);
+ assert_eq!(is.monsters.len(), was.monsters.len());
+ assert!(is.log.last().unwrap().contains("where you left off"));
+ assert_eq!(back.brought, ctx.hold.gear, "what's found is what you didn't bring");
+ assert_eq!(ctx.record.boarded, 1, "coming back isn't boarding again");
+ let seen = |c: &Crawl| c.map.positions().filter(|&p| c.seen(p)).count();
+ assert_eq!(seen(is), seen(was), "what you'd seen you still have");
+ assert!(was.map.positions().all(|p| is.seen(p) == was.seen(p)));
+ }
+
+ #[test]
+ fn medpatches_go_aboard_and_come_back_if_you_do() {
+ let mut ctx = ctx();
+ ctx.hold.gear.add(medpatches(3));
+ ctx.hold.lockers.push(medpatches(4));
+ let mut scene = Derelict::board(3, &mut ctx);
+ assert_eq!((scene.crawl.gear.medpatches(), ctx.hold.medpatches()), (3, 7));
+ let key = key_toward_airlock(&scene);
+ press(&mut scene, key, &mut ctx);
+ assert_eq!(scene.crawl.outcome, Some(Outcome::Escaped));
+ assert_eq!(ctx.hold.gear.medpatches(), 3);
+
+ let mut scene = Derelict::board(3, &mut ctx);
+ abandon(&mut scene, &mut ctx);
+ assert_eq!(ctx.hold.gear.medpatches(), 0, "abandon ship and they stay behind");
+ assert_eq!(ctx.hold.medpatches(), 4, "but not what's in the lockers");
+ }
+
+ #[test]
+ fn weapons_go_aboard_and_come_back_if_you_do() {
+ let mut ctx = ctx();
+ let mut cleaver = arms::find(&mut seed::rng(2), Source::Crew, false);
+ cleaver.loaded = 0;
+ ctx.hold.gear.add(Stack::one(Item::Weapon(cleaver.clone())));
+ let found = |g: &Gear| g.weapons().filter(|w| !w.kit).count();
+ let mut scene = Derelict::board(3, &mut ctx);
+ assert_eq!((found(&scene.crawl.gear), found(&ctx.hold.gear)), (1, 1));
+ assert!(scene.crawl.gear.weapons().all(|w| w.loaded == w.gun.map_or(0, |g| g.magazine)));
+ let key = key_toward_airlock(&scene);
+ press(&mut scene, key, &mut ctx);
+ assert_eq!(found(&ctx.hold.gear), 1);
+ assert!(screen(&scene, &ctx).contains("nothing new"), "what you brought isn't found");
+
+ let mut scene = Derelict::board(3, &mut ctx);
+ abandon(&mut scene, &mut ctx);
+ assert_eq!(found(&ctx.hold.gear), 0, "abandon ship and it stays behind");
+ assert_eq!(ctx.hold.gear.weapons().count(), 2, "but not your own torch and rivet gun");
+ }
+
+ #[test]
+ fn aiming_and_the_inventory_open_and_close() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ press(&mut scene, KeyCode::Char('f'), &mut ctx);
+ assert!(matches!(scene.mode, Mode::Aim(_)));
+ assert!(screen(&scene, &ctx).contains("next target"));
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(matches!(scene.mode, Mode::Explore));
+ assert_eq!(scene.crawl.turns, 0, "lowering your gun takes no time");
+
+ press(&mut scene, KeyCode::Char('i'), &mut ctx);
+ let shown = screen(&scene, &ctx);
+ assert!(shown.contains("YOU ·") && shown.contains("cutting torch"), "{shown}");
+ press(&mut scene, KeyCode::Char('j'), &mut ctx);
+ assert_eq!(scene.crawl.turns, 0, "looking takes no time");
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(scene.inventory.is_none());
+ }
+
+ #[test]
+ fn using_things_from_the_inventory_takes_a_turn_and_closes_it() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ scene.crawl.gear.add(Stack::one(salvage()));
+ scene.crawl.gear.add(medpatches(1));
+ scene.crawl.hp -= 5;
+ press(&mut scene, KeyCode::Char('i'), &mut ctx);
+ find(&mut scene, "coil", &mut ctx);
+ press(&mut scene, KeyCode::Char('d'), &mut ctx);
+ assert_eq!(scene.crawl.turns, 0, "dropping is free");
+ assert!(scene.inventory.is_some(), "and leaves it open");
+ assert_eq!(scene.crawl.here().len(), 1);
+ find(&mut scene, "medpatch", &mut ctx);
+ press(&mut scene, KeyCode::Char('a'), &mut ctx);
+ assert_eq!(scene.crawl.turns, 1, "a medpatch takes a turn");
+ assert!(scene.inventory.is_none(), "and closes it");
+ }
+
+ /// Picks out the first thing in the open inventory called `what`.
+ fn find(scene: &mut Derelict, what: &str, ctx: &mut Ctx) {
+ press(scene, KeyCode::Char('/'), ctx);
+ press(scene, KeyCode::Esc, ctx);
+ press(scene, KeyCode::Char('/'), ctx);
+ for c in what.chars() {
+ press(scene, KeyCode::Char(c), ctx);
+ }
+ press(scene, KeyCode::Enter, ctx);
+ }
+
+ #[test]
+ fn firing_takes_a_turn_and_spends_a_round() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ press(&mut scene, KeyCode::Char('f'), &mut ctx);
+ scene.mode = Mode::Aim(scene.crawl.player.offset(2, 0));
+ ctx.ticks = 100;
+ press(&mut scene, KeyCode::Char('f'), &mut ctx);
+ assert_eq!(scene.crawl.turns, 1);
+ assert_eq!(scene.crawl.gear.gun.loaded, scene.crawl.gear.gun.gun.unwrap().magazine - 1);
+ assert!(!scene.crawl.volley.is_empty());
+ assert_eq!(scene.volley_at, 100);
+ assert!(scene.in_flight(100_000).is_empty(), "long since landed");
+ }
+
+ #[test]
+ fn your_record_counts_wrecks_kills_and_how_you_left_them() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::board(3, &mut ctx);
+ let kind = scene.crawl.monsters[0].kind.name;
+ scene.crawl.monsters[0].hp = 0;
+ press(&mut scene, KeyCode::Char('.'), &mut ctx);
+ assert_eq!(ctx.record.slain.get(kind), Some(&1));
+ let key = key_toward_airlock(&scene);
+ press(&mut scene, key, &mut ctx);
+
+ let mut scene = Derelict::board(3, &mut ctx);
+ abandon(&mut scene, &mut ctx);
+
+ ctx.hold.gear.add(medpatches(2));
+ let mut scene = Derelict::board(3, &mut ctx);
+ scene.crawl.bleeding = 100;
+ press(&mut scene, KeyCode::Char('.'), &mut ctx);
+ assert!(matches!(scene.crawl.outcome, Some(Outcome::Died { .. })));
+ assert!(screen(&scene, &ctx).contains(&ctx.character.ship_name()));
+ assert_eq!(ctx.hold.gear.medpatches(), 0, "brought down, and they stay behind");
+
+ let r = &ctx.record;
+ assert_eq!((r.boarded, r.escaped, r.abandoned, r.downed), (3, 1, 1, 1));
+ }
+
+ #[test]
+ fn until_you_leave_the_wreck_your_hold_is_as_you_left_it() {
+ let mut ctx = ctx();
+ ctx.hold.gear.add(medpatches(2));
+ let mut scene = Derelict::board(3, &mut ctx);
+ scene.crawl.gear = Gear::default();
+ press(&mut scene, KeyCode::Char('.'), &mut ctx);
+ assert_eq!(ctx.hold.gear.medpatches(), 2, "so a game saved now still has them");
+ }
+
+ #[test]
+ fn what_you_learn_you_keep() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::board(3, &mut ctx);
+ scene.crawl.known.insert(("rust-mite", Dmg::Blunt));
+ press(&mut scene, KeyCode::Char('.'), &mut ctx);
+ assert!(ctx.bestiary.contains(&("rust-mite", Dmg::Blunt)));
+ let scene = Derelict::board(4, &mut ctx);
+ assert!(scene.crawl.known.contains(&("rust-mite", Dmg::Blunt)));
+ }
}
diff --git a/src/scenes/flight.rs b/src/scenes/flight.rs
new file mode 100644
index 0000000..04c56d0
--- /dev/null
+++ b/src/scenes/flight.rs
@@ -0,0 +1,1163 @@
+//! Flying around one place: the local map, the ship, the laser, and a side
+//! panel with a radar and your status. The rules live in `space::flight`;
+//! this turns keys into `Controls` and draws the result.
+//!
+//! On terminals that report key releases, you hold keys to thrust, turn and
+//! fire. Elsewhere there's no telling when a key is let go, so thrust is a
+//! throttle you step up and down, each turn press swings you round 22.5°,
+//! and each laser press fires for a moment.
+
+use std::f64::consts::{FRAC_PI_2, PI};
+
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Padding, Paragraph, Wrap};
+
+use super::aboard::Aboard;
+use super::character::Sheet;
+use super::derelict::Derelict;
+use super::galaxy_map::GalaxyMap;
+use super::keys;
+use super::pause::{Choice, Pause};
+use super::port::{FUEL, Port};
+use super::sky::{self, Surface, View, mix};
+use super::system_map::SystemMap;
+use super::{Scene, Transition};
+use crate::app::{Ctx, Hold, TICKS_PER_SECOND};
+use crate::seed;
+use crate::space::Vec2;
+use crate::space::flight::{
+ Controls, Event, FLOTSAM_LIFE, Flight, LANDING_SPEED, SPARK_LIFE, THRUST, TURN_RATE,
+};
+use crate::space::locale::{self, BACKDROP_PARALLAX, Size};
+use crate::space::system::Landing;
+use crate::trade;
+
+const DT: f64 = 1.0 / TICKS_PER_SECOND as f64;
+const PANEL_WIDTH: u16 = 30;
+/// Below this width the panel gives way to a one-line status bar.
+const PANEL_MIN_SCREEN: u16 = 76;
+/// Throttle steps, for terminals without held keys.
+const THROTTLE_STEPS: u8 = 4;
+/// How far one turn press swings you, without held keys.
+const TURN_STEP: f64 = PI / 8.0;
+/// How long one laser press fires for, without held keys.
+const FIRE_TICKS: u64 = TICKS_PER_SECOND as u64 * 6 / 10;
+/// Seconds a message stays up.
+const MESSAGE_SECONDS: u64 = 6;
+
+const HULL: Color = Color::Rgb(112, 118, 140);
+const ROCK: (u8, u8, u8) = (150, 138, 124);
+const BEAM: Color = Color::Rgb(255, 110, 220);
+const DIM: Color = Color::Rgb(90, 94, 112);
+const ACCENT: Color = Color::Rgb(240, 200, 110);
+
+/// Why you're out of the pilot's seat, walking about the ship: that scene
+/// is on top of this one until you sit back down.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Walking {
+ /// Landed or docked.
+ Landed,
+ /// Got up while flying slowly, and the ship holds station.
+ Adrift,
+}
+
+/// What tab points you at.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Target {
+ /// Whichever rock with ore in it is nearest right now.
+ NearestOre,
+ /// The planet, moon or station in the middle.
+ Middle,
+ /// A wreck, by index into `locale.wrecks`.
+ Wreck(usize),
+}
+
+pub struct FlightScene {
+ flight: Flight,
+ /// Which system, place and day `flight` was built for. When the map
+ /// moves you on, these fall out of date and the scene rebuilds.
+ star: usize,
+ place: usize,
+ day: f64,
+ throttle: u8,
+ /// Turning still to do, in radians, from turn presses.
+ pending_turn: f64,
+ /// Turning to face backwards and thrusting until stopped.
+ braking: bool,
+ fire_until: u64,
+ target: Option<Target>,
+ messages: Vec<(String, u64)>,
+ walking: Option<Walking>,
+ /// The menu `esc` opens. Everything holds still while it's open.
+ pause: Option<Pause>,
+}
+
+impl FlightScene {
+ pub fn new(ctx: &Ctx) -> FlightScene {
+ let mut scene = FlightScene {
+ flight: arrive(ctx),
+ star: ctx.star,
+ place: ctx.location,
+ day: ctx.day,
+ throttle: 0,
+ pending_turn: 0.0,
+ braking: false,
+ fire_until: 0,
+ target: None,
+ messages: Vec::new(),
+ walking: None,
+ pause: None,
+ };
+ let l = &scene.flight.locale;
+ let text = format!("You're {}.", l.what);
+ scene.say(text, ctx);
+ scene
+ }
+
+ fn say(&mut self, text: String, ctx: &Ctx) {
+ self.messages.retain(|(t, _)| *t != text);
+ self.messages.push((text, ctx.ticks));
+ if self.messages.len() > 6 {
+ self.messages.remove(0);
+ }
+ }
+
+ /// If the map has moved you on (or let time pass), arrives at the new
+ /// place.
+ fn catch_up(&mut self, ctx: &mut Ctx) {
+ if self.star == ctx.star && self.place == ctx.location && self.day == ctx.day {
+ return;
+ }
+ let days = ctx.day - self.day;
+ let moved = self.place != ctx.location;
+ let jumped = self.star != ctx.star;
+ self.flight = arrive(ctx);
+ self.star = ctx.star;
+ self.place = ctx.location;
+ self.day = ctx.day;
+ self.throttle = 0;
+ self.pending_turn = 0.0;
+ self.braking = false;
+ self.target = None;
+ self.messages.clear();
+ let l = &self.flight.locale;
+ let note = ctx.arrival_note.take();
+ let text = if let Some(note) = note {
+ note.replace("{where}", &l.what)
+ } else if jumped {
+ format!(
+ "{} later you arrive in the {} system, {}.",
+ days_text(days),
+ ctx.system.name,
+ l.what
+ )
+ } else if moved {
+ format!("{} later you arrive {}.", days_text(days), l.what)
+ } else if (days - 1.0).abs() < 0.05 {
+ "A day passes, and the rocks drift on.".to_string()
+ } else {
+ format!("{} pass, and the rocks drift on.", days_text(days))
+ };
+ self.say(text, ctx);
+ if jumped && ctx.stranded() {
+ let text =
+ "Your tank's dry, and no one here sells fuel. [g] the star map, to call a tow.";
+ self.say(text.to_string(), ctx);
+ }
+ }
+
+ /// Back in the pilot's seat, if you'd got up.
+ fn sit_down(&mut self, ctx: &mut Ctx) {
+ let Some(walking) = self.walking.take() else { return };
+ ctx.landed = false;
+ let name = &ctx.system.places[self.place].name;
+ let text = match walking {
+ Walking::Landed if matches!(ctx.system.landing(self.place), Landing::Market(_)) => {
+ format!("You undock from {name}.")
+ }
+ Walking::Landed => format!("You lift off from {name}."),
+ Walking::Adrift => "You sit back down and take the controls.".to_string(),
+ };
+ self.say(text, ctx);
+ }
+
+ fn held(ctx: &Ctx, codes: &[KeyCode]) -> bool {
+ codes.iter().any(|&c| ctx.is_held(c))
+ }
+
+ fn controls(&mut self, ctx: &Ctx) -> Controls {
+ let mut c = Controls::default();
+ if ctx.keyboard.key_release {
+ let left = Self::held(ctx, &[KeyCode::Left, KeyCode::Char('a')]);
+ let right = Self::held(ctx, &[KeyCode::Right, KeyCode::Char('d')]);
+ let thrust = Self::held(ctx, &[KeyCode::Up, KeyCode::Char('w')]);
+ if left || right || thrust {
+ self.braking = false;
+ }
+ if Self::held(ctx, &[KeyCode::Down, KeyCode::Char('s')]) {
+ self.braking = true;
+ }
+ c.turn = f64::from(u8::from(right)) - f64::from(u8::from(left));
+ c.thrust = f64::from(u8::from(thrust));
+ c.firing = Self::held(ctx, &[KeyCode::Char(' '), KeyCode::Char('f')]);
+ } else {
+ let step = TURN_RATE * DT;
+ c.turn = (self.pending_turn / step).clamp(-1.0, 1.0);
+ self.pending_turn -= c.turn * step;
+ c.thrust = f64::from(self.throttle) / f64::from(THROTTLE_STEPS);
+ c.firing = ctx.ticks < self.fire_until;
+ }
+ if self.braking {
+ let speed = self.flight.speed();
+ if speed < 0.3 {
+ // Close enough: hold station.
+ self.flight.ship.vel = Vec2::ZERO;
+ self.braking = false;
+ self.throttle = 0;
+ } else {
+ c.retro = true;
+ // Full thrust against the drift, easing off on the last tick
+ // so as not to overshoot.
+ let enough = speed / (THRUST * DT);
+ c.thrust = if self.flight.facing_retro() { enough.min(1.0) } else { 0.0 };
+ }
+ }
+ c
+ }
+
+ fn key_fallback(&mut self, code: KeyCode, ctx: &Ctx) -> bool {
+ match code {
+ KeyCode::Up | KeyCode::Char('w') => {
+ self.throttle = (self.throttle + 1).min(THROTTLE_STEPS);
+ self.braking = false;
+ }
+ KeyCode::Down | KeyCode::Char('s') => {
+ if self.throttle > 0 {
+ self.throttle -= 1;
+ } else {
+ self.braking = true;
+ }
+ }
+ KeyCode::Left | KeyCode::Char('a') => self.turn_by(-TURN_STEP),
+ KeyCode::Right | KeyCode::Char('d') => self.turn_by(TURN_STEP),
+ KeyCode::Char(' ' | 'f') => self.fire_until = ctx.ticks + FIRE_TICKS,
+ KeyCode::Char('x') => self.throttle = 0,
+ _ => return false,
+ }
+ true
+ }
+
+ fn turn_by(&mut self, angle: f64) {
+ self.braking = false;
+ self.pending_turn = (self.pending_turn + angle).clamp(-FRAC_PI_2, FRAC_PI_2);
+ }
+
+ fn land(&mut self, ctx: &mut Ctx) -> Transition {
+ let f = &self.flight;
+ let l = &f.locale;
+ let fast = f.speed() > LANDING_SPEED;
+ let refusal = if let Some(body) = &l.body {
+ if !f.over_body() {
+ format!("Fly over {} to land.", body.name)
+ } else if body.kind.giant {
+ format!(
+ "{} has no surface to land on: it's {} all the way down.",
+ body.name, body.kind.name
+ )
+ } else if fast {
+ format!("Too fast to land. Slow below {LANDING_SPEED:.0}.")
+ } else {
+ return self.touch_down(ctx);
+ }
+ } else if let Some(dock) = &l.dock {
+ if !f.at_dock() {
+ format!("Fly in close to {} to dock.", dock.name)
+ } else if fast {
+ format!("Too fast to dock. Slow below {LANDING_SPEED:.0}.")
+ } else {
+ return self.touch_down(ctx);
+ }
+ } else {
+ "There's nowhere to land out here.".to_string()
+ };
+ self.say(refusal, ctx);
+ Transition::None
+ }
+
+ /// Lands, or docks. Outside a capital you get up from the controls,
+ /// and the way into the city is at the other end of the ship; anywhere
+ /// else there's no city to walk to, so you're straight out at the dock,
+ /// or on the ground.
+ fn touch_down(&mut self, ctx: &mut Ctx) -> Transition {
+ self.stop();
+ self.walking = Some(Walking::Landed);
+ ctx.landed = true;
+ Transition::Push(landed(ctx, self.place))
+ }
+
+ /// Gets up from the controls to walk about the ship, if you're going
+ /// slowly enough to leave it holding station.
+ fn get_up(&mut self, ctx: &mut Ctx) -> Transition {
+ if self.flight.speed() > LANDING_SPEED {
+ let text = format!("Too fast to leave the controls. Slow below {LANDING_SPEED:.0}.");
+ self.say(text, ctx);
+ return Transition::None;
+ }
+ let wreck =
+ self.flight.wreck_alongside().map(|i| self.flight.locale.wrecks[i].wreck.clone());
+ self.stop();
+ self.walking = Some(Walking::Adrift);
+ Transition::Push(Box::new(Aboard::adrift(&self.flight.locale.what, wreck)))
+ }
+
+ fn stop(&mut self) {
+ self.flight.ship.vel = Vec2::ZERO;
+ self.throttle = 0;
+ self.braking = false;
+ self.pending_turn = 0.0;
+ }
+
+ fn board(&mut self, ctx: &mut Ctx) -> Transition {
+ let Some(i) = self.flight.wreck_alongside() else {
+ let text = if self.flight.locale.wrecks.is_empty() {
+ "There are no wrecks here."
+ } else {
+ "Fly alongside a wreck to board it."
+ };
+ self.say(text.to_string(), ctx);
+ return Transition::None;
+ };
+ let wreck = &self.flight.locale.wrecks[i].wreck;
+ if self.flight.speed() > LANDING_SPEED {
+ let text = format!("Too fast to board. Slow below {LANDING_SPEED:.0}.");
+ self.say(text, ctx);
+ Transition::None
+ } else if ctx.stripped.contains(&wreck.seed) {
+ let text = format!("You've already stripped {}. Nothing's left aboard.", wreck.name);
+ self.say(text, ctx);
+ Transition::None
+ } else {
+ let seed = wreck.seed;
+ ctx.stripped.insert(seed);
+ self.stop();
+ Transition::Push(Box::new(Derelict::board(seed, ctx)))
+ }
+ }
+
+ fn next_target(&mut self) {
+ let l = &self.flight.locale;
+ let mut targets = vec![];
+ if l.rocks.iter().any(|r| r.ore.is_some()) {
+ targets.push(Target::NearestOre);
+ }
+ if l.body.is_some() || l.dock.is_some() {
+ targets.push(Target::Middle);
+ }
+ targets.extend((0..l.wrecks.len()).map(Target::Wreck));
+ self.target = match self.target.and_then(|t| targets.iter().position(|&x| x == t)) {
+ Some(i) if i + 1 < targets.len() => Some(targets[i + 1]),
+ Some(_) => None,
+ None => targets.first().copied(),
+ };
+ }
+
+ /// Where the target is and what to call it.
+ fn target_info(&self) -> Option<(Vec2, f64, String)> {
+ let l = &self.flight.locale;
+ let ship = self.flight.ship.pos;
+ match self.target? {
+ Target::NearestOre => {
+ let rock = l
+ .rocks
+ .iter()
+ .filter(|r| r.ore.is_some())
+ .min_by(|a, b| a.pos.distance(ship).total_cmp(&b.pos.distance(ship)))?;
+ let size = match rock.size {
+ Size::Small => "small",
+ Size::Medium => "",
+ Size::Large => "large",
+ };
+ let name = format!("{size} rock, {}", rock.ore.unwrap().name);
+ Some((rock.pos, rock.size.radius(), name.trim_start().to_string()))
+ }
+ Target::Middle => match (&l.body, &l.dock) {
+ (Some(b), _) => Some((Vec2::ZERO, b.radius, b.name.clone())),
+ (None, Some(d)) => Some((Vec2::ZERO, locale::DOCK_RADIUS, d.name.clone())),
+ (None, None) => None,
+ },
+ Target::Wreck(i) => {
+ let w = l.wrecks.get(i)?;
+ Some((w.pos, 2.0, format!("wreck of {}", w.wreck.name)))
+ }
+ }
+ }
+}
+
+/// The scenes that pick up a game where it was left: flying wherever you
+/// are, and, if you'd set down there, aboard your ship on the ground.
+/// Where a saved game picks up: flying, aboard your ship where it's landed,
+/// or aboard the wreck you were in.
+pub fn resume(ctx: &Ctx) -> Vec<Box<dyn Scene>> {
+ let mut flying = FlightScene::new(ctx);
+ let mut scenes: Vec<Box<dyn Scene>> = if ctx.landed {
+ // Where you'd have been to land: over the planet, or at the dock.
+ flying.flight.ship.pos = Vec2::ZERO;
+ flying.walking = Some(Walking::Landed);
+ let landed = if has_city(ctx, ctx.location) {
+ Box::new(Aboard::resumed(ctx, ctx.location))
+ } else {
+ landed(ctx, ctx.location)
+ };
+ vec![Box::new(flying), landed]
+ } else {
+ vec![Box::new(flying)]
+ };
+ if let Some(wreck) = Derelict::resumed(ctx) {
+ scenes.push(Box::new(wreck));
+ }
+ scenes
+}
+
+fn has_city(ctx: &Ctx, place: usize) -> bool {
+ matches!(ctx.system.landing(place), Landing::Capital(_))
+}
+
+/// Where you are once you've set down at `place`: aboard, if there's a city
+/// to walk out to, or else straight out at the dock or on the ground.
+fn landed(ctx: &Ctx, place: usize) -> Box<dyn Scene> {
+ if has_city(ctx, place) {
+ Box::new(Aboard::landed(ctx, place))
+ } else {
+ Box::new(Port::new(ctx, place))
+ }
+}
+
+fn arrive(ctx: &Ctx) -> Flight {
+ let locale = locale::generate(&ctx.system, ctx.location, ctx.day, ctx.departed, &ctx.stripped);
+ let seed = seed::derive(ctx.seed, "arrival", ctx.day.to_bits() ^ ctx.location as u64);
+ Flight::new(locale, seed)
+}
+
+fn days_text(days: f64) -> String {
+ if days < 1.0 {
+ format!("{:.0} hours", days * 24.0)
+ } else if (days - 1.0).abs() < 0.05 {
+ "A day".to_string()
+ } else {
+ format!("{days:.1} days")
+ }
+}
+
+impl Scene for FlightScene {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ if let Some(pause) = &mut self.pause {
+ return match pause.key(key) {
+ None => Transition::None,
+ Some(choice) => {
+ self.pause = None;
+ match choice {
+ Choice::Quit => Transition::Title,
+ Choice::Here | Choice::Resume => Transition::None,
+ }
+ }
+ };
+ }
+ self.sit_down(ctx);
+ self.catch_up(ctx);
+ let repeat = key.kind == KeyEventKind::Repeat;
+ if !ctx.keyboard.key_release && self.key_fallback(key.code, ctx) {
+ return Transition::None;
+ }
+ if repeat {
+ return Transition::None;
+ }
+ match key.code {
+ KeyCode::Char(c) => match c.to_ascii_lowercase() {
+ 'l' => self.land(ctx),
+ 'b' => self.board(ctx),
+ 'e' => self.get_up(ctx),
+ 'm' => Transition::Push(Box::new(SystemMap::new(ctx))),
+ 'g' => Transition::Push(Box::new(GalaxyMap::new(ctx))),
+ 'c' => Transition::Push(Box::new(Sheet::new())),
+ 'r' => {
+ self.braking = true;
+ Transition::None
+ }
+ _ => Transition::None,
+ },
+ KeyCode::Tab => {
+ self.next_target();
+ Transition::None
+ }
+ KeyCode::Esc => {
+ let note = format!(
+ "You're {}. Everything holds still while this is open.",
+ self.flight.locale.what
+ );
+ self.pause = Some(Pause::new(None, Some(note)));
+ Transition::None
+ }
+ _ => Transition::None,
+ }
+ }
+
+ fn tick(&mut self, ctx: &mut Ctx) -> Transition {
+ if self.pause.is_some() {
+ return Transition::None;
+ }
+ self.sit_down(ctx);
+ self.catch_up(ctx);
+ let controls = self.controls(ctx);
+ self.flight.laser_power = trade::laser_power(ctx.lenses);
+ for event in self.flight.step(DT, controls, ctx.hold.ore_room()) {
+ let text = match event {
+ Event::Collected(ore) => {
+ *ctx.hold.ore.entry(ore.name).or_default() += 1;
+ format!("Scooped up a ton of {}. Hold: {} t.", ore.name, ore_line(&ctx.hold))
+ }
+ Event::Shattered { ore: Some(ore), .. } => {
+ format!("The rock splits, spilling {ore}.")
+ }
+ Event::Shattered { ore: None, .. } => continue,
+ Event::HoldFull => {
+ "Your hold is full of ore. Land somewhere to sell it.".to_string()
+ }
+ };
+ self.say(text, ctx);
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let area = frame.area();
+ let (map, panel) = if area.width >= PANEL_MIN_SCREEN {
+ let [map, panel] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
+ .areas(area);
+ (map, Some(panel))
+ } else {
+ (area, None)
+ };
+ let view = View { area: map, camera: self.flight.ship.pos };
+ self.draw_map(frame.buffer_mut(), &view, ctx);
+ self.draw_messages(frame.buffer_mut(), map, ctx);
+ match panel {
+ Some(panel) => self.draw_panel(frame, panel, ctx),
+ None => self.draw_status_bar(frame.buffer_mut(), map, ctx),
+ }
+ if let Some(pause) = &self.pause {
+ pause.render(frame, area);
+ }
+ }
+}
+
+impl FlightScene {
+ fn draw_map(&self, buf: &mut Buffer, view: &View, ctx: &Ctx) {
+ let f = &self.flight;
+ let l = &f.locale;
+ let look = seed::derive(ctx.system.seed, "look", l.place as u64);
+ sky::starfield(buf, view.area, view.camera, look);
+
+ for (i, b) in l.backdrops.iter().enumerate() {
+ let at = b.pos + view.camera * (1.0 - BACKDROP_PARALLAX);
+ let surface = Surface {
+ rgb: b.kind.rgb,
+ banded: b.kind.banded,
+ lights: false,
+ ramp: sky::PLANET_RAMP,
+ seed: seed::derive(look, "backdrop", i as u64),
+ };
+ let (col, row) = view.screen(at);
+ if !sky::sphere(buf, view.area, (col, row), b.radius, l.sun, &surface) {
+ view.put(buf, at, '·', sky::rgb(b.kind.rgb));
+ }
+ let below = row + b.radius / 2.0 + 1.0;
+ sky::label(buf, view.area, col, below, &b.name, Color::Rgb(64, 66, 82));
+ }
+
+ self.draw_edge(buf, view);
+
+ if let Some(body) = &l.body {
+ let surface = Surface {
+ rgb: body.kind.rgb,
+ banded: body.kind.banded,
+ lights: body.settled,
+ ramp: sky::PLANET_RAMP,
+ seed: look,
+ };
+ let (col, row) = view.screen(Vec2::ZERO);
+ sky::sphere(buf, view.area, (col, row), body.radius, l.sun, &surface);
+ let below = row + body.radius / 2.0 + 1.0;
+ sky::label(buf, view.area, col, below, &body.name, DIM);
+ }
+ if let Some(dock) = &l.dock {
+ sky::draw_station(buf, view, dock.culture, ctx.ticks);
+ let (col, row) = view.screen(Vec2::ZERO);
+ sky::label(buf, view.area, col, row + 3.0, &dock.name, DIM);
+ }
+
+ for w in &l.wrecks {
+ let wreck = &w.wreck;
+ let beacon = !ctx.stripped.contains(&wreck.seed) && wreck.calling(ctx.day);
+ sky::draw_wreck(buf, view, w.pos, w.heading, beacon, ctx.ticks);
+ if w.pos.distance(f.ship.pos) < 30.0 {
+ let (col, row) = view.screen(w.pos);
+ sky::label(buf, view.area, col, row + 1.0, &wreck.name, DIM);
+ }
+ }
+
+ for rock in &l.rocks {
+ let mut rgb = ROCK;
+ if let Some(ore) = rock.ore {
+ rgb = mix(rgb, ore.rgb, 0.55);
+ }
+ // Glows hotter as the laser works on it.
+ let worn = 1.0 - rock.integrity / rock.size.toughness();
+ rgb = mix(rgb, (255, 150, 90), worn * 0.8);
+ let surface =
+ Surface { rgb, banded: false, lights: false, ramp: sky::ROCK_RAMP, seed: 0 };
+ let drawn = rock.size != Size::Small
+ && sky::sphere(
+ buf,
+ view.area,
+ view.screen(rock.pos),
+ rock.size.radius(),
+ l.sun,
+ &surface,
+ );
+ if !drawn {
+ view.put(buf, rock.pos, 'o', sky::shade(rgb, 1.0));
+ }
+ }
+
+ for bit in &f.flotsam {
+ let fading = FLOTSAM_LIFE - bit.age < 8.0 && ctx.ticks % 10 < 5;
+ if !fading {
+ view.put(buf, bit.pos, '*', sky::shade(bit.ore.rgb, 1.15));
+ }
+ }
+
+ for spark in &f.sparks {
+ let k = spark.age / SPARK_LIFE;
+ let colour = mix((255, 226, 150), (150, 50, 30), k);
+ view.put(buf, spark.pos, if k < 0.5 { '∙' } else { '·' }, sky::rgb(colour));
+ }
+
+ if let Some(beam) = &f.beam {
+ let length = beam.from.distance(beam.to);
+ let dir = (beam.to - beam.from).normalised();
+ let steps = (length * 2.0) as usize;
+ for i in 0..steps {
+ view.put(buf, beam.from + dir * (i as f64 * 0.5), '·', BEAM);
+ }
+ if beam.hit {
+ let glyph = if ctx.ticks % 4 < 2 { '*' } else { '+' };
+ view.put(buf, beam.to, glyph, Color::Rgb(255, 236, 200));
+ }
+ }
+
+ if let Some((pos, r, _)) = self.target_info() {
+ let reach = r + 1.5;
+ view.put(buf, Vec2::new(pos.x - reach, pos.y), '[', ACCENT);
+ view.put(buf, Vec2::new(pos.x + reach, pos.y), ']', ACCENT);
+ }
+
+ sky::draw_ship(buf, view, &f.ship, ctx.ticks);
+ }
+
+ /// Faint dots along the edge of the map, where it starts pushing back.
+ fn draw_edge(&self, buf: &mut Buffer, view: &View) {
+ let e = self.flight.locale.extent;
+ let colour = Color::Rgb(56, 58, 74);
+ let a = view.area;
+ let (half_w, half_h) = (f64::from(a.width) / 2.0 + 1.0, f64::from(a.height) + 2.0);
+ let (cx, cy) = (view.camera.x, view.camera.y);
+ for x in [-e, e] {
+ if (x - cx).abs() <= half_w {
+ let (from, to) = ((cy - half_h).max(-e), (cy + half_h).min(e));
+ let mut y = (from / 2.0).ceil() * 2.0;
+ while y <= to {
+ view.put(buf, Vec2::new(x, y), '┊', colour);
+ y += 2.0;
+ }
+ }
+ }
+ for y in [-e, e] {
+ if (y - cy).abs() <= half_h {
+ let (from, to) = ((cx - half_w).max(-e), (cx + half_w).min(e));
+ let mut x = from.ceil();
+ while x <= to {
+ view.put(buf, Vec2::new(x, y), '┈', colour);
+ x += 1.0;
+ }
+ }
+ }
+ }
+
+ fn draw_messages(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) {
+ let fresh: Vec<&String> = self
+ .messages
+ .iter()
+ .filter(|(_, at)| {
+ ctx.ticks.saturating_sub(*at) < MESSAGE_SECONDS * u64::from(TICKS_PER_SECOND)
+ })
+ .map(|(t, _)| t)
+ .collect();
+ let shown = fresh.iter().rev().take(3).rev();
+ let count = fresh.len().min(3) as u16;
+ let first = area.bottom().saturating_sub(count + 1);
+ if self.flight.past_edge() {
+ let warning = "You're leaving the vicinity. [m] opens the map, to travel on.";
+ text_at(buf, area, first.saturating_sub(1), warning, ACCENT);
+ }
+ for (row, text) in (first..).zip(shown) {
+ text_at(buf, area, row, text, Color::Rgb(220, 220, 230));
+ }
+ }
+
+ fn draw_status_bar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) {
+ let text = format!(
+ " {} · day {:.1} · speed {:.0} · ore {} · fuel {} ",
+ self.flight.locale.name,
+ ctx.day,
+ self.flight.speed(),
+ ore_line(&ctx.hold),
+ ctx.hold.fuel
+ );
+ let mut line = keys::line(&[("m", "map"), ("g", "stars"), ("esc", "menu")]);
+ line.spans.insert(0, Span::raw(text).fg(ACCENT));
+ let bar = Rect { height: 1, ..area };
+ buf.set_style(bar, Style::new().bg(Color::Rgb(20, 20, 30)));
+ buf.set_line(area.x, area.y, &line, area.width);
+ }
+
+ fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
+ let l = &self.flight.locale;
+ let block = Block::bordered()
+ .title(format!(" {} ", l.name))
+ .border_style(Style::new().fg(DIM))
+ .title_style(Style::new().fg(ACCENT).bold())
+ .padding(Padding::horizontal(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let hints: &[(&str, &str)] = if ctx.keyboard.key_release {
+ &[
+ ("↑ w", "thrust"),
+ ("← → a d", "turn"),
+ ("↓ s r", "brake"),
+ ("space f", "laser"),
+ ("l", "land, dock"),
+ ("b", "board"),
+ ("e", "get up"),
+ ("tab", "target"),
+ ("m", "system map"),
+ ("g", "star map"),
+ ("c", "character"),
+ ("esc", "menu: save and quit"),
+ ]
+ } else {
+ &[
+ ("↑ ↓ w s", "throttle"),
+ ("← → a d", "turn"),
+ ("r", "brake"),
+ ("x", "cut engines"),
+ ("space f", "laser"),
+ ("b", "board"),
+ ("l", "land, dock"),
+ ("e", "get up"),
+ ("tab", "target"),
+ ("c", "character"),
+ ("m", "system map"),
+ ("g", "star map"),
+ ("esc", "menu: save and quit"),
+ ]
+ };
+ let hints = keys::packed(hints, inner.width);
+ // What's left after the text, up to a square the width of the panel.
+ let text_rows = 4 + 6 + 3 + 1 + hints.len() as u16;
+ let radar_rows = inner.height.saturating_sub(text_rows).min(inner.width / 2).min(13);
+ let [about, radar, status, target, _, keys] = Layout::vertical([
+ Constraint::Length(4),
+ Constraint::Length(radar_rows),
+ Constraint::Length(6),
+ Constraint::Length(3),
+ Constraint::Fill(1),
+ Constraint::Length(hints.len() as u16),
+ ])
+ .areas(inner);
+
+ let about_lines = vec![
+ Line::from(l.what.clone()).fg(DIM),
+ Line::from(format!("{} system, day {:.1}", ctx.system.name, ctx.day)).fg(DIM),
+ ];
+ frame.render_widget(Paragraph::new(about_lines).wrap(Wrap { trim: true }), about);
+
+ if radar.height >= 3 {
+ self.draw_radar(frame.buffer_mut(), radar, ctx);
+ }
+
+ let f = &self.flight;
+ let mut lines = vec![Line::from(vec![
+ Span::raw("speed ").fg(DIM),
+ Span::raw(format!("{:>4.1}", f.speed())),
+ if f.speed() <= LANDING_SPEED {
+ Span::raw(" · can land").fg(DIM)
+ } else {
+ Span::raw("")
+ },
+ ])];
+ if !ctx.keyboard.key_release {
+ let bars: String =
+ (1..=THROTTLE_STEPS).map(|i| if i <= self.throttle { '▮' } else { '▯' }).collect();
+ lines.push(Line::from(vec![Span::raw("thrust ").fg(DIM), Span::raw(bars).fg(ACCENT)]));
+ }
+ if self.braking {
+ lines.push(Line::from("braking").fg(ACCENT));
+ }
+ let h = &ctx.hold;
+ lines.push(Line::from(vec![Span::raw("ore ").fg(DIM), Span::raw(ore_line(h))]));
+ let gauge: String =
+ (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect();
+ lines.push(Line::from(vec![
+ Span::raw("fuel ").fg(DIM),
+ Span::raw(gauge).fg(if h.fuel == 0 { Color::Rgb(214, 112, 100) } else { FUEL }),
+ ]));
+ lines.push(Line::from(vec![
+ Span::raw("hold ").fg(DIM),
+ Span::raw(format!("{} artifacts, {} ¤", h.artifacts(), h.scrip)),
+ ]));
+ frame.render_widget(Paragraph::new(lines), status);
+
+ let target_lines = match self.target_info() {
+ Some((pos, _, name)) => {
+ let off = pos - f.ship.pos;
+ vec![
+ Line::from(name).fg(ACCENT),
+ Line::from(format!("{} {:.0} away", sky::arrow(off.heading()), off.length())),
+ ]
+ }
+ None if self.target == Some(Target::NearestOre) => {
+ vec![Line::from("no ore in sight").fg(DIM)]
+ }
+ None => vec![keys::line(&[("tab", "pick a target")])],
+ };
+ frame.render_widget(Paragraph::new(target_lines), target);
+
+ frame.render_widget(Paragraph::new(hints), keys);
+ }
+
+ /// The whole local map, shrunk to fit.
+ fn draw_radar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) {
+ let l = &self.flight.locale;
+ let w = f64::from(area.width.min(area.height * 2));
+ let h = w / 2.0;
+ let left = f64::from(area.x) + (f64::from(area.width) - w) / 2.0;
+ let top = f64::from(area.y);
+ let e = l.extent;
+ let cell = |p: Vec2| {
+ let col = left + (p.x + e) / (2.0 * e) * w;
+ let row = top + (p.y + e) / (2.0 * e) * h;
+ let inside = col >= left && col < left + w && row >= top && row < top + h;
+ inside.then_some((col as u16, row as u16))
+ };
+ for row in 0..h as u16 {
+ for col in 0..w as u16 {
+ buf[(left as u16 + col, area.y + row)].set_char('·').set_fg(Color::Rgb(40, 42, 54));
+ }
+ }
+ let mut put = |p: Vec2, ch: char, fg: Color| {
+ if let Some(pos) = cell(p) {
+ buf[pos].set_char(ch).set_fg(fg);
+ }
+ };
+ if let Some(body) = &l.body {
+ let step = 2.0 * e / w;
+ let mut y = -body.radius;
+ while y <= body.radius {
+ let mut x = -body.radius;
+ while x <= body.radius {
+ if x * x + y * y <= body.radius * body.radius {
+ put(Vec2::new(x, y), 'o', sky::shade(body.kind.rgb, 0.7));
+ }
+ x += step;
+ }
+ y += step * 2.0;
+ }
+ }
+ if l.dock.is_some() {
+ put(Vec2::ZERO, '#', ACCENT);
+ }
+ for rock in &l.rocks {
+ let fg = rock.ore.map_or(Color::Rgb(110, 104, 96), |o| sky::rgb(o.rgb));
+ put(rock.pos, '∙', fg);
+ }
+ for bit in &self.flight.flotsam {
+ put(bit.pos, '*', sky::rgb(bit.ore.rgb));
+ }
+ for wreck in &l.wrecks {
+ let stripped = ctx.stripped.contains(&wreck.wreck.seed);
+ put(wreck.pos, '≡', if stripped { HULL } else { Color::Rgb(255, 90, 80) });
+ }
+ let ship = &self.flight.ship;
+ put(ship.pos, sky::arrow(ship.heading), Color::White);
+ }
+}
+
+/// One line of text from the left of `area`, a cell in, clipped to fit.
+fn text_at(buf: &mut Buffer, area: Rect, row: u16, text: &str, fg: Color) {
+ if row < area.y || row >= area.bottom() || area.width < 3 {
+ return;
+ }
+ let text: String = text.chars().take(usize::from(area.width - 2)).collect();
+ buf.set_string(area.x + 1, row, text, Style::new().fg(fg));
+}
+
+/// "12/40 t".
+fn ore_line(hold: &Hold) -> String {
+ format!("{}/{} t", hold.ore_tons(), hold.ore_capacity)
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::scenes::tests::test_ctx;
+ use crate::space::system::PlaceKind;
+
+ fn press(scene: &mut FlightScene, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ fn ticks(scene: &mut FlightScene, ctx: &mut Ctx, n: u32) {
+ for _ in 0..n {
+ ctx.ticks += 1;
+ scene.tick(ctx);
+ }
+ }
+
+ #[test]
+ fn esc_holds_everything_still_and_esc_again_carries_on() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ for _ in 0..4 {
+ press(&mut scene, &mut ctx, KeyCode::Up);
+ }
+ ticks(&mut scene, &mut ctx, 5);
+ press(&mut scene, &mut ctx, KeyCode::Esc);
+ let held = scene.flight.ship.pos;
+ ticks(&mut scene, &mut ctx, 30);
+ assert_eq!(scene.flight.ship.pos, held, "nothing moves while the menu's open");
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None));
+ ticks(&mut scene, &mut ctx, 5);
+ assert_ne!(scene.flight.ship.pos, held, "and carries on after");
+ press(&mut scene, &mut ctx, KeyCode::Esc);
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('2')), Transition::Title));
+ }
+
+ #[test]
+ fn the_throttle_flies_you_forward_and_brake_stops_you() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ let start = scene.flight.ship.pos;
+ for _ in 0..4 {
+ press(&mut scene, &mut ctx, KeyCode::Up);
+ }
+ ticks(&mut scene, &mut ctx, 15);
+ assert!(scene.flight.speed() > 5.0);
+ assert!(scene.flight.ship.pos.distance(start) > 1.0);
+ press(&mut scene, &mut ctx, KeyCode::Char('x'));
+ press(&mut scene, &mut ctx, KeyCode::Char('r'));
+ ticks(&mut scene, &mut ctx, 150);
+ assert!(scene.flight.speed() < 0.5, "braked to {}", scene.flight.speed());
+ assert!(!scene.braking);
+ }
+
+ #[test]
+ fn turn_presses_swing_you_round_an_eighth_of_a_half_turn() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ let before = scene.flight.ship.heading;
+ press(&mut scene, &mut ctx, KeyCode::Right);
+ ticks(&mut scene, &mut ctx, 10);
+ let turned = crate::space::turn_between(before, scene.flight.ship.heading);
+ assert!((turned - TURN_STEP).abs() < 1e-9, "turned {turned}");
+ }
+
+ #[test]
+ fn travelling_on_the_map_rebuilds_the_place() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap();
+ ctx.location = belt;
+ ctx.day = 14.0;
+ ticks(&mut scene, &mut ctx, 1);
+ assert_eq!(scene.place, belt);
+ assert!(scene.flight.locale.body.is_none() && scene.flight.locale.rocks.len() > 50);
+ assert!(scene.messages.last().unwrap().0.contains("14.0 days later"));
+ }
+
+ #[test]
+ fn jumping_brings_you_out_at_the_edge_of_another_system() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ let mut map = GalaxyMap::new(&ctx);
+ map.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx);
+ map.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut ctx);
+ ticks(&mut scene, &mut ctx, 1);
+ assert_eq!((scene.star, scene.place), (ctx.star, ctx.system.edge_place()));
+ let said = &scene.messages.last().unwrap().0;
+ assert!(said.contains(&format!("edge of the {} system", ctx.system.name)), "{said}");
+ assert!(!said.contains("{where}"), "{said}");
+ assert!(ctx.arrival_note.is_none());
+ }
+
+ #[test]
+ fn you_land_only_over_the_planet_and_slowly() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None));
+ assert!(scene.messages.last().unwrap().0.starts_with("Fly over"));
+ scene.flight.ship.pos = Vec2::ZERO;
+ scene.flight.ship.vel = Vec2::new(20.0, 0.0);
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None));
+ assert!(scene.messages.last().unwrap().0.starts_with("Too fast"));
+ scene.flight.ship.vel = Vec2::new(2.0, 0.0);
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Push(_)));
+ // Walking back to the pilot's seat pops the ship's inside off, and
+ // the next tick finds you back at the controls.
+ ticks(&mut scene, &mut ctx, 1);
+ assert!(scene.messages.last().unwrap().0.starts_with("You lift off from"));
+ }
+
+ #[test]
+ fn without_a_city_you_land_straight_at_the_dock() {
+ let mut ctx = test_ctx();
+ let station =
+ ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap();
+ ctx.location = station;
+ let mut scene = FlightScene::new(&ctx);
+ let Transition::Push(mut docked) = scene.touch_down(&mut ctx) else { panic!("docks") };
+ let l = KeyEvent::from(KeyCode::Char('l'));
+ assert!(matches!(docked.key(l, &mut ctx), Transition::Pop), "l undocks, from the dock");
+ ticks(&mut scene, &mut ctx, 1);
+ assert!(scene.messages.last().unwrap().0.starts_with("You undock from"));
+ assert!(!ctx.landed);
+
+ // A game saved there picks up there too.
+ ctx.landed = true;
+ let mut stack = resume(&ctx);
+ assert!(matches!(stack[1].key(l, &mut ctx), Transition::Pop));
+ }
+
+ #[test]
+ fn you_pick_up_where_you_were_landed_or_flying() {
+ let mut ctx = test_ctx();
+ assert_eq!(resume(&ctx).len(), 1, "flying");
+ let mut scene = FlightScene::new(&ctx);
+ scene.flight.ship.pos = Vec2::ZERO;
+ press(&mut scene, &mut ctx, KeyCode::Char('l'));
+ assert!(ctx.landed);
+ let mut stack = resume(&ctx);
+ assert_eq!(stack.len(), 2, "aboard, landed");
+ ticks(&mut scene, &mut ctx, 1);
+ assert!(!ctx.landed, "taking off");
+
+ // Taking off from a game picked up landed is from where you landed.
+ ctx.landed = true;
+ stack[0].tick(&mut ctx);
+ assert!(!ctx.landed);
+ }
+
+ #[test]
+ fn you_get_up_from_the_controls_only_when_slow() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ scene.flight.ship.vel = Vec2::new(20.0, 0.0);
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::None));
+ assert!(scene.messages.last().unwrap().0.starts_with("Too fast"));
+ scene.flight.ship.vel = Vec2::new(2.0, 0.0);
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::Push(_)));
+ assert_eq!(scene.flight.speed(), 0.0, "the ship holds station");
+ ticks(&mut scene, &mut ctx, 1);
+ assert!(scene.messages.last().unwrap().0.contains("take the controls"));
+ }
+
+ #[test]
+ fn scooped_ore_goes_in_the_hold() {
+ let mut ctx = test_ctx();
+ let mut scene = FlightScene::new(&ctx);
+ let ore = &locale::ORES[2];
+ let pos = scene.flight.ship.pos;
+ scene.flight.flotsam.push(crate::space::flight::Flotsam {
+ pos,
+ vel: Vec2::ZERO,
+ ore,
+ age: 0.0,
+ });
+ ticks(&mut scene, &mut ctx, 1);
+ assert_eq!(ctx.hold.ore.get("iridium"), Some(&1));
+ }
+
+ #[test]
+ fn boarding_a_wreck_strips_it() {
+ let mut ctx = test_ctx();
+ let wreck = ctx.system.wrecks_on(ctx.day)[0].clone();
+ ctx.location = wreck.place;
+ let mut scene = FlightScene::new(&ctx);
+ let w = scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).unwrap();
+ scene.flight.ship.pos = w.pos;
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::Push(_)));
+ assert!(ctx.stripped.contains(&wreck.seed));
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::None));
+ assert!(scene.messages.last().unwrap().0.contains("already stripped"));
+ // The hulk stays while you're here, and is gone when you come back.
+ let again = FlightScene::new(&ctx);
+ assert!(again.flight.locale.wrecks.iter().all(|w| w.wreck != wreck));
+ }
+
+ #[test]
+ fn wrecks_you_left_alone_drift_off_in_time() {
+ let mut ctx = test_ctx();
+ let wreck = ctx.system.wrecks_on(ctx.day)[0].clone();
+ ctx.location = wreck.place;
+ let here = |ctx: &Ctx| {
+ let scene = FlightScene::new(ctx);
+ scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).map(|w| w.pos)
+ };
+ let first = here(&ctx).expect("calling today");
+ ctx.day = (ctx.day + wreck.leaves) / 2.0;
+ assert_eq!(here(&ctx), Some(first), "still there, in the same spot");
+ ctx.day = wreck.leaves + 0.5;
+ assert_eq!(here(&ctx), None, "its beacon failed, and it's gone");
+ }
+
+ #[test]
+ fn a_wreck_you_set_out_for_waits_for_you() {
+ let mut ctx = test_ctx();
+ let wreck = ctx.system.wrecks_on(ctx.day)[0].clone();
+ let elsewhere = (0..ctx.system.places.len()).find(|&p| p != wreck.place).unwrap();
+ ctx.location = elsewhere;
+ let here = |ctx: &Ctx| {
+ let scene = FlightScene::new(ctx);
+ scene.flight.locale.wrecks.iter().any(|w| w.wreck == wreck)
+ };
+ // A trip so slow that its beacon fails on the way.
+ ctx.travel(wreck.place, wreck.leaves - ctx.day + 1.0);
+ assert!(!wreck.calling(ctx.day));
+ assert!(here(&ctx), "you came by its last fix");
+ assert!(ctx.wrecks().contains(&wreck), "and the map knows it's here");
+ ctx.day += 3.0;
+ assert!(here(&ctx), "it stays while you do");
+ ctx.travel(elsewhere, 1.0);
+ assert!(!ctx.wrecks().contains(&wreck));
+ ctx.travel(wreck.place, 1.0);
+ assert!(!here(&ctx), "come back another time and it's gone");
+ }
+}
diff --git a/src/scenes/forge.rs b/src/scenes/forge.rs
index f856c8f..a49e098 100644
--- a/src/scenes/forge.rs
+++ b/src/scenes/forge.rs
@@ -10,6 +10,7 @@ use ratatui::style::{Color, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap};
+use super::keys;
use super::{Scene, Transition};
use crate::app::Ctx;
use crate::names::{self, Artifact, Culture};
@@ -51,7 +52,8 @@ impl Forge {
}
}
-fn colour(culture: Culture) -> Color {
+/// Each people's colour, wherever they're shown.
+pub fn colour(culture: Culture) -> Color {
match culture {
Culture::Qaari => Color::Yellow,
Culture::Cant => Color::Red,
@@ -147,7 +149,11 @@ impl Scene for Forge {
frame.render_widget(Paragraph::new(lines).block(block), artifacts);
frame.render_widget(
- Paragraph::new(" [space/→] forge again [←] previous [esc] back").dark_gray(),
+ Paragraph::new(keys::line(&[
+ ("space →", "forge again"),
+ ("←", "previous"),
+ ("esc", "back"),
+ ])),
footer,
);
}
diff --git a/src/scenes/galaxy_map.rs b/src/scenes/galaxy_map.rs
new file mode 100644
index 0000000..5059b01
--- /dev/null
+++ b/src/scenes/galaxy_map.rs
@@ -0,0 +1,929 @@
+//! The star map: every system in the galaxy, the lanes between them, and
+//! whose space is whose.
+//!
+//! The map is drawn at one scale, a light-year to a cell across (two down),
+//! and scrolls to keep the chosen system in view; an inset in the corner
+//! shows the whole galaxy and where the view is. Each people's territory is
+//! a faint stipple in their colour round the systems they hold, brighter
+//! along the borders.
+//!
+//! You know the way only to systems you've charted: those you've been to
+//! and those one lane from them. Choose one and the map plots the fewest
+//! jumps there through charted space; `enter` makes them, a jump's worth of
+//! fuel at a time. Beyond the charted systems are faint dots, unnamed,
+//! except for the four seats, which everyone knows.
+//!
+//! Run dry where no one sells fuel and `t` calls a tow, to the nearest
+//! system that does. It costs what it costs: what you can't pay, you owe.
+
+use std::collections::HashSet;
+
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::symbols::Marker;
+use ratatui::text::{Line, Span};
+use ratatui::widgets::canvas::{Canvas, Line as Lane, Points};
+use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
+
+use super::keys;
+use super::port::FUEL;
+use super::sky;
+use super::system_map::{FAILING, FAINT_WRECK, SystemMap, wrap};
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::names::{self, Culture};
+use crate::space::Vec2;
+use crate::space::galaxy::Galaxy;
+use crate::space::system::{Landing, Rgb, System, SystemKind};
+use crate::trade;
+
+const PANEL_WIDTH: u16 = 44;
+/// Below this width the panel goes under the map instead of beside it.
+const SIDE_BY_SIDE: u16 = 90;
+const ACCENT: Color = Color::Rgb(240, 200, 110);
+const DIM: Color = Color::Rgb(110, 114, 132);
+const LANE: Color = Color::Rgb(86, 92, 116);
+const FAINT_LANE: Color = Color::Rgb(50, 53, 68);
+const NO_FUEL: Color = Color::Rgb(214, 112, 100);
+/// How far a people's territory reaches past their outermost stars, in
+/// light-years.
+const REACH: f64 = 11.0;
+/// The inset showing the whole galaxy, inside its border.
+const INSET: (u16, u16) = (26, 8);
+
+/// A people's colour on the map: the colour of their stations.
+pub fn people_rgb(culture: Culture) -> Rgb {
+ sky::station_art(culture).1
+}
+
+pub struct GalaxyMap {
+ selected: usize,
+ /// The chosen system, generated, if you've been there and it isn't
+ /// where you are (that's `ctx.system`).
+ known: Option<System>,
+ /// What happened when you last tried to go somewhere.
+ report: Option<String>,
+}
+
+impl GalaxyMap {
+ pub fn new(ctx: &Ctx) -> GalaxyMap {
+ GalaxyMap { selected: ctx.star, known: None, report: None }
+ }
+
+ fn select(&mut self, site: usize, ctx: &Ctx) {
+ if site == self.selected {
+ return;
+ }
+ self.selected = site;
+ self.report = None;
+ self.known =
+ (site != ctx.star && ctx.visited.contains(&site)).then(|| ctx.galaxy.system(site));
+ }
+
+ /// The system nearest the chosen one in a direction (y up), within a
+ /// cone either side of it.
+ fn step(&mut self, dir: Vec2, ctx: &Ctx) {
+ let g = &ctx.galaxy;
+ let from = g.sites[self.selected].pos;
+ let best = g
+ .sites
+ .iter()
+ .enumerate()
+ .filter(|&(i, _)| i != self.selected)
+ .filter_map(|(i, s)| {
+ let off = s.pos - from;
+ let d = off.length();
+ let cos = off.dot(dir) / d;
+ (cos > 0.5).then_some((i, d * (1.0 + 2.0 * (1.0 - cos))))
+ })
+ .min_by(|a, b| a.1.total_cmp(&b.1));
+ if let Some((i, _)) = best {
+ self.select(i, ctx);
+ }
+ }
+
+ /// The next system a lane from here leads to.
+ fn next_lane(&mut self, ctx: &Ctx) {
+ let lanes = &ctx.galaxy.sites[ctx.star].lanes;
+ let next = match lanes.iter().position(|&l| l == self.selected) {
+ Some(i) => lanes[(i + 1) % lanes.len()],
+ None => lanes[0],
+ };
+ self.select(next, ctx);
+ }
+
+ /// The jumps from here to the chosen system, through charted space.
+ fn route(&self, ctx: &Ctx, charted: &[bool]) -> Option<Vec<usize>> {
+ if !charted[self.selected] {
+ return None;
+ }
+ ctx.galaxy.route(ctx.star, self.selected, |i| charted[i])
+ }
+
+ /// Jumps to the chosen system, if you can.
+ fn travel(&mut self, ctx: &mut Ctx) -> Transition {
+ let g = &ctx.galaxy;
+ let to = self.selected;
+ let charted = g.charted(&ctx.visited);
+ let refusal = if to == ctx.star {
+ Some(format!("You're in {} already.", g.sites[to].name))
+ } else if !charted[to] {
+ Some(
+ "That system's uncharted: you don't know the lanes that far. Jump closer first."
+ .into(),
+ )
+ } else {
+ None
+ };
+ if let Some(refusal) = refusal {
+ self.report = Some(refusal);
+ return Transition::None;
+ }
+ let Some(route) = self.route(ctx, &charted) else {
+ self.report = Some("There's no charted route there.".into());
+ return Transition::None;
+ };
+ let jumps = route.len() as u32;
+ let fuel = ctx.hold.fuel;
+ if fuel < jumps {
+ self.report = Some(if fuel == 0 && ctx.stranded() {
+ "Your tank's dry, and no one here sells fuel. [t] calls a tow.".into()
+ } else if fuel == 0 {
+ "Your tank's dry. Fuel's sold here: at a station, or a city's shipyard.".into()
+ } else {
+ format!(
+ "That's {jumps} jumps, and you've fuel for {}. Choose somewhere nearer, \
+ or fill up first.",
+ plural(fuel, "jump")
+ )
+ });
+ return Transition::None;
+ }
+ let days = g.route_days(ctx.star, &route);
+ let name = g.sites[to].name.clone();
+ ctx.day += days;
+ ctx.hold.fuel -= jumps;
+ ctx.visited.extend(route.iter().copied());
+ ctx.enter_system(to);
+ ctx.arrival_note = Some(format!(
+ "{} and {} later, you come out of the jump at the edge of the {name} system, {{where}}.",
+ capitalise(&plural(jumps, "jump")),
+ days_text(days)
+ ));
+ Transition::Pop
+ }
+
+ /// Calls a tow, if you're stranded.
+ fn tow(&mut self, ctx: &mut Ctx) -> Transition {
+ if !ctx.stranded() {
+ self.report = Some(if ctx.hold.fuel > 0 {
+ "You've fuel enough to jump. No one tows a ship that can fly.".into()
+ } else {
+ "Fuel's for sale here, at a station or a city's shipyard. No tow will come for that."
+ .into()
+ });
+ return Transition::None;
+ }
+ let g = &ctx.galaxy;
+ let Some(route) = g.nearest(ctx.star, |i| g.sites[i].kind.sells_fuel()) else {
+ self.report = Some("No one answers.".into());
+ return Transition::None;
+ };
+ let to = *route.last().unwrap();
+ let fee = trade::tow_fee(route.len());
+ // Towing is slow: twice as long as jumping yourself.
+ let days = 2.0 * g.route_days(ctx.star, &route);
+ let name = g.sites[to].name.clone();
+ ctx.day += days;
+ ctx.visited.extend(route.iter().copied());
+ let owed = ctx.hold.charge(fee);
+ ctx.enter_system(to);
+ if let Some(place) = ctx.system.fuel_place() {
+ ctx.location = place;
+ }
+ let bill = if owed == 0 {
+ format!("The fee is {fee} ¤, and you pay it.")
+ } else {
+ format!(
+ "The fee is {fee} ¤. You pay what you can, and owe {owed} ¤; \
+ what you earn goes to it first."
+ )
+ };
+ ctx.arrival_note = Some(format!(
+ "{} later the tow casts you off in the {name} system, {{where}}. {bill}",
+ capitalise(&days_text(days))
+ ));
+ Transition::Pop
+ }
+}
+
+impl Scene for GalaxyMap {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('w' | 'k') => self.step(Vec2::new(0.0, 1.0), ctx),
+ KeyCode::Down | KeyCode::Char('s' | 'j') => self.step(Vec2::new(0.0, -1.0), ctx),
+ KeyCode::Left | KeyCode::Char('a' | 'h') => self.step(Vec2::new(-1.0, 0.0), ctx),
+ KeyCode::Right | KeyCode::Char('d' | 'l') => self.step(Vec2::new(1.0, 0.0), ctx),
+ KeyCode::Tab => self.next_lane(ctx),
+ KeyCode::Char('.') => self.select(ctx.star, ctx),
+ KeyCode::Enter => return self.travel(ctx),
+ KeyCode::Char('t') => return self.tow(ctx),
+ KeyCode::Char('m') => return Transition::Replace(vec![Box::new(SystemMap::new(ctx))]),
+ KeyCode::Esc | KeyCode::Char('g' | 'q') => return Transition::Pop,
+ _ => {}
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let area = frame.area();
+ let (map, panel) = if area.width >= SIDE_BY_SIDE {
+ let [map, panel] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
+ .areas(area);
+ (map, panel)
+ } else {
+ let rows = (area.height / 2).min(16);
+ let [map, panel] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(area);
+ (map, panel)
+ };
+ let charted = ctx.galaxy.charted(&ctx.visited);
+ let route = self.route(ctx, &charted).unwrap_or_default();
+ if map.width >= 4 && map.height >= 2 {
+ let view = View::new(&ctx.galaxy, map, ctx.galaxy.sites[self.selected].pos);
+ self.draw_map(frame, &view, ctx, &charted, &route);
+ if map.width >= 60 && map.height >= 18 && !view.shows_all(&ctx.galaxy) {
+ draw_inset(frame.buffer_mut(), map, &view, ctx, self.selected);
+ }
+ }
+ self.draw_panel(frame, panel, ctx, &charted, &route);
+ }
+}
+
+/// Which part of the galaxy is on screen: a light-year to a cell across,
+/// two to a cell down.
+struct View {
+ area: Rect,
+ left: f64,
+ top: f64,
+}
+
+impl View {
+ /// Centred on `focus`, as far as that doesn't scroll off into empty
+ /// space.
+ fn new(galaxy: &Galaxy, area: Rect, focus: Vec2) -> View {
+ let (low, high) = galaxy.bounds();
+ let margin = 8.0;
+ let w = f64::from(area.width);
+ let h = f64::from(area.height) * 2.0;
+ let fit = |focus: f64, low: f64, high: f64, span: f64| {
+ if high - low + 2.0 * margin <= span {
+ (low + high) / 2.0
+ } else {
+ focus.clamp(low - margin + span / 2.0, high + margin - span / 2.0)
+ }
+ };
+ let cx = fit(focus.x, low.x, high.x, w);
+ let cy = fit(focus.y, low.y, high.y, h);
+ View { area, left: cx - w / 2.0, top: cy + h / 2.0 }
+ }
+
+ fn right(&self) -> f64 {
+ self.left + f64::from(self.area.width)
+ }
+
+ fn bottom(&self) -> f64 {
+ self.top - 2.0 * f64::from(self.area.height)
+ }
+
+ /// The cell a point falls in, if it's on screen.
+ fn cell(&self, p: Vec2) -> Option<(u16, u16)> {
+ let col = (p.x - self.left).floor();
+ let row = ((self.top - p.y) / 2.0).floor();
+ let inside = col >= 0.0
+ && row >= 0.0
+ && col < f64::from(self.area.width)
+ && row < f64::from(self.area.height);
+ inside.then(|| (self.area.x + col as u16, self.area.y + row as u16))
+ }
+
+ fn shows_all(&self, galaxy: &Galaxy) -> bool {
+ let (low, high) = galaxy.bounds();
+ low.x >= self.left && high.x < self.right() && high.y <= self.top && low.y > self.bottom()
+ }
+}
+
+/// Which people's territory a point is in, if any, and whether it's near
+/// a border.
+fn territory(galaxy: &Galaxy, p: Vec2) -> Option<(Culture, bool)> {
+ let mut nearest = (f64::MAX, Culture::Qaari);
+ for s in &galaxy.sites {
+ let d = s.pos.distance(p);
+ if d < nearest.0 {
+ nearest = (d, s.culture);
+ }
+ }
+ if nearest.0 > REACH {
+ return None;
+ }
+ let other = galaxy
+ .sites
+ .iter()
+ .filter(|s| s.culture != nearest.1)
+ .map(|s| s.pos.distance(p))
+ .fold(f64::MAX, f64::min);
+ Some((nearest.1, other - nearest.0 < 1.6))
+}
+
+impl GalaxyMap {
+ fn draw_map(
+ &self,
+ frame: &mut Frame,
+ view: &View,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ ) {
+ let g = &ctx.galaxy;
+ let area = view.area;
+
+ // Territory: a faint dot in every other cell, and every cell along
+ // a border. Each people's dots are drawn together, in their colour.
+ let mut tint: Vec<(Color, Vec<(f64, f64)>)> = Vec::new();
+ for c in Culture::ALL {
+ let rgb = people_rgb(c);
+ tint.push((sky::shade(rgb, 0.5), Vec::new()));
+ tint.push((sky::shade(rgb, 0.85), Vec::new()));
+ }
+ for row in 0..area.height {
+ for col in 0..area.width {
+ let p = Vec2::new(
+ view.left + f64::from(col) + 0.25,
+ view.top - 2.0 * f64::from(row) - 0.75,
+ );
+ let Some((culture, border)) = territory(g, p) else { continue };
+ if border || (col + row) % 2 == 0 {
+ tint[culture as usize * 2 + usize::from(border)].1.push((p.x, p.y));
+ }
+ }
+ }
+ let on_route = |a: usize, b: usize| {
+ let mut stops = vec![ctx.star];
+ stops.extend(route);
+ stops.windows(2).any(|w| (w[0] == a && w[1] == b) || (w[0] == b && w[1] == a))
+ };
+ let canvas = Canvas::default()
+ .marker(Marker::Braille)
+ .background_color(Color::Reset)
+ .x_bounds([view.left, view.right()])
+ .y_bounds([view.bottom(), view.top])
+ .paint(|c| {
+ for (color, coords) in &tint {
+ c.draw(&Points { coords, color: *color });
+ }
+ c.layer();
+ for (i, s) in g.sites.iter().enumerate() {
+ for &j in s.lanes.iter().filter(|&&j| j > i) {
+ let colour = match (charted[i], charted[j]) {
+ _ if on_route(i, j) => continue,
+ (true, true) => LANE,
+ (true, false) | (false, true) => FAINT_LANE,
+ (false, false) => continue,
+ };
+ let t = &g.sites[j];
+ c.draw(&Lane::new(s.pos.x, s.pos.y, t.pos.x, t.pos.y, colour));
+ }
+ }
+ c.layer();
+ let mut at = g.sites[ctx.star].pos;
+ for &next in route {
+ let to = g.sites[next].pos;
+ c.draw(&Lane::new(at.x, at.y, to.x, to.y, ACCENT));
+ at = to;
+ }
+ });
+ frame.render_widget(canvas, area);
+ self.draw_stars(frame.buffer_mut(), view, ctx, charted, route);
+ }
+
+ /// Each star, then names, most important first, wherever they fit.
+ fn draw_stars(
+ &self,
+ buf: &mut Buffer,
+ view: &View,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ ) {
+ let g = &ctx.galaxy;
+ let mut taken: HashSet<(u16, u16)> = HashSet::new();
+ for (i, s) in g.sites.iter().enumerate() {
+ let Some(cell) = view.cell(s.pos) else { continue };
+ let rgb = people_rgb(s.culture);
+ let (glyph, style) = if i == ctx.star {
+ ("@", Style::new().fg(Color::White).bold())
+ } else if g.is_seat(i) && (charted[i] || ctx.visited.contains(&i)) {
+ ("◉", Style::new().fg(sky::rgb(rgb)).bold())
+ } else if g.is_seat(i) {
+ ("◉", Style::new().fg(sky::shade(rgb, 0.7)))
+ } else if ctx.visited.contains(&i) {
+ ("●", Style::new().fg(sky::rgb(rgb)))
+ } else if charted[i] {
+ ("○", Style::new().fg(sky::rgb(rgb)))
+ } else {
+ ("·", Style::new().fg(sky::shade(rgb, 0.75)))
+ };
+ buf[cell].set_symbol(glyph).set_style(style);
+ taken.insert(cell);
+ if i == self.selected {
+ for (dx, bracket) in [(-1i32, "["), (1, "]")] {
+ let x = i32::from(cell.0) + dx;
+ if x >= i32::from(view.area.x) && x < i32::from(view.area.right()) {
+ buf[(x as u16, cell.1)].set_symbol(bracket).set_fg(ACCENT);
+ taken.insert((x as u16, cell.1));
+ }
+ }
+ }
+ }
+
+ // Who gets a name, in order: you, the choice, the way there, the
+ // seats, where you've been, what's charted.
+ let mut order: Vec<usize> = vec![ctx.star, self.selected];
+ order.extend(route);
+ order.extend((0..g.sites.len()).filter(|&i| g.is_seat(i)));
+ order.extend(ctx.visited.iter().copied());
+ order.extend((0..g.sites.len()).filter(|&i| charted[i]));
+ let mut named = HashSet::new();
+ for i in order {
+ if !named.insert(i) {
+ continue;
+ }
+ let s = &g.sites[i];
+ let Some((col, row)) = view.cell(s.pos) else { continue };
+ let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i);
+ let text = if known { s.name.clone() } else { "uncharted".to_string() };
+ let fg = if i == self.selected {
+ ACCENT
+ } else if i == ctx.star {
+ Color::White
+ } else if ctx.visited.contains(&i) && !s.kind.sells_fuel() {
+ NO_FUEL
+ } else if ctx.visited.contains(&i) {
+ Color::Rgb(200, 204, 216)
+ } else if charted[i] {
+ Color::Rgb(148, 152, 170)
+ } else {
+ sky::shade(people_rgb(s.culture), 0.7)
+ };
+ let start = col + 2;
+ let width = text.chars().count() as u16;
+ let end = start.saturating_add(width).min(view.area.right());
+ if start >= end {
+ continue;
+ }
+ let must = i == ctx.star || i == self.selected;
+ let clear = (start.saturating_sub(1)..end + 1).all(|x| !taken.contains(&(x, row)));
+ if !(must || clear) {
+ continue;
+ }
+ let shown: String = text.chars().take(usize::from(end - start)).collect();
+ buf.set_string(start, row, shown, Style::new().fg(fg));
+ for x in start..end {
+ taken.insert((x, row));
+ }
+ }
+ }
+
+ fn draw_panel(
+ &self,
+ frame: &mut Frame,
+ area: Rect,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ ) {
+ let block = Block::bordered()
+ .title(format!(" star map · day {:.1} ", ctx.day))
+ .title_style(Style::new().fg(ACCENT).bold())
+ .border_style(Style::new().fg(DIM));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let mut hints = vec![("←↑↓→", "choose"), ("tab", "lanes"), ("enter", "jump")];
+ if ctx.stranded() {
+ hints.push(("t", "tow"));
+ }
+ hints.extend([(".", "where you are"), ("m", "system map"), ("esc", "back")]);
+ let hints = keys::packed(&hints, inner.width);
+ let [about, keys] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(2 + hints.len() as u16)])
+ .areas(inner);
+ let width = usize::from(about.width);
+ frame.render_widget(
+ Paragraph::new(self.about(ctx, charted, route, width)).wrap(Wrap { trim: false }),
+ about,
+ );
+
+ let mut legend = vec![
+ Line::from(vec![
+ Span::raw("@").fg(Color::White).bold(),
+ Span::raw(" you ").fg(DIM),
+ Span::raw("◉").fg(DIM),
+ Span::raw(" seat ").fg(DIM),
+ Span::raw("●").fg(DIM),
+ Span::raw(" been ").fg(DIM),
+ Span::raw("○").fg(DIM),
+ Span::raw(" charted ").fg(DIM),
+ Span::raw("·").fg(DIM),
+ Span::raw(" not").fg(DIM),
+ ]),
+ Line::from(
+ Culture::ALL
+ .into_iter()
+ .flat_map(|c| {
+ [
+ Span::raw("■ ").fg(sky::rgb(people_rgb(c))),
+ Span::raw(format!("{} ", c.adjective())).fg(DIM),
+ ]
+ })
+ .collect::<Vec<_>>(),
+ ),
+ ];
+ legend.extend(hints);
+ frame.render_widget(Paragraph::new(legend), keys);
+ }
+
+ /// What's known about the chosen system, and the way there.
+ fn about(
+ &self,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ width: usize,
+ ) -> Vec<Line<'static>> {
+ let g = &ctx.galaxy;
+ let i = self.selected;
+ let site = &g.sites[i];
+ let h = &ctx.hold;
+ let dim = Style::new().fg(DIM);
+ let plain = Style::new();
+ let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)];
+
+ let gauge: String =
+ (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect();
+ let mut lines = vec![Line::from(vec![
+ Span::raw(format!("{:<8}", "fuel")).fg(DIM),
+ Span::raw(gauge).fg(FUEL),
+ Span::raw(format!(" {} of {}", h.fuel, plural(h.fuel_capacity, "jump"))),
+ ])];
+ if h.debt > 0 {
+ lines.push(Line::from(vec![
+ Span::raw(format!("{:<8}", "owed")).fg(DIM),
+ Span::raw(format!("{} ¤, paid from what you earn", h.debt)),
+ ]));
+ }
+ if ctx.stranded() {
+ lines.push(Line::default());
+ let (to, fee) = match g.nearest(ctx.star, |s| g.sites[s].kind.sells_fuel()) {
+ Some(r) => (g.sites[*r.last().unwrap()].name.clone(), trade::tow_fee(r.len())),
+ None => ("nowhere".into(), 0),
+ };
+ lines.extend(wrap(
+ vec![Span::raw("Stranded. ").fg(NO_FUEL).bold()],
+ &[(
+ format!(
+ "Your tank's dry and no one here sells fuel. [t] calls a tow to {to}, \
+ for {fee} ¤; what you can't pay, you'll owe."
+ ),
+ plain,
+ )],
+ width,
+ ));
+ }
+ lines.push(Line::default());
+
+ let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i);
+ let title = if known { site.name.clone() } else { "An uncharted system".into() };
+ lines.push(Line::from(title).fg(ACCENT).bold());
+ let whose = site.culture.name();
+ let what = if ctx.visited.contains(&i) {
+ match site.kind {
+ SystemKind::Seat => format!("the seat of {whose}"),
+ kind => format!("{} of {whose}", kind.name()),
+ }
+ } else if g.is_seat(i) {
+ format!("the seat of {whose}; you've not been there")
+ } else if charted[i] {
+ format!("in the space of {whose}; you've not been there")
+ } else {
+ format!("in the space of {whose}, past what you've charted")
+ };
+ let head = vec![Span::raw("■ ").fg(sky::rgb(people_rgb(site.culture)))];
+ lines.extend(wrap(head, &[(what, plain)], width));
+ // The way there.
+ if i == ctx.star {
+ let lanes: Vec<String> = site.lanes.iter().map(|&l| g.sites[l].name.clone()).collect();
+ lines.extend(wrap(
+ label("lanes"),
+ &[(format!("to {}", join_names(&lanes)), plain)],
+ width,
+ ));
+ } else if !charted[i] {
+ lines.extend(wrap(
+ Vec::new(),
+ &[("Uncharted: jump closer to learn the way.".into(), dim)],
+ width,
+ ));
+ } else if route.is_empty() {
+ lines.extend(wrap(Vec::new(), &[("No charted route there.".into(), dim)], width));
+ } else {
+ let jumps = route.len() as u32;
+ let days = g.route_days(ctx.star, route);
+ let short = h.fuel < jumps;
+ let fuel_text = format!("· {} of fuel", plural(jumps, "jump"));
+ lines.extend(wrap(
+ label("route"),
+ &[
+ (format!("{}, {}", plural(jumps, "jump"), days_text(days)), plain),
+ (
+ fuel_text,
+ if short { Style::new().fg(NO_FUEL) } else { Style::new().fg(FUEL) },
+ ),
+ ],
+ width,
+ ));
+ if route.len() > 1 {
+ let via: Vec<String> =
+ route[..route.len() - 1].iter().map(|&s| g.sites[s].name.clone()).collect();
+ lines.extend(wrap(label("via"), &[(via.join(" → "), plain)], width));
+ }
+ if short {
+ lines.extend(wrap(
+ Vec::new(),
+ &[(
+ format!("You've fuel for {}.", plural(h.fuel, "jump")),
+ Style::new().fg(NO_FUEL),
+ )],
+ width,
+ ));
+ }
+ }
+ if let Some(report) = &self.report {
+ lines.push(Line::default());
+ lines.extend(wrap(Vec::new(), &[(report.clone(), Style::new().fg(ACCENT))], width));
+ }
+ lines.push(Line::default());
+
+ // What it is, and what's there if you've been.
+ if known {
+ lines.extend(wrap(
+ label("star"),
+ &[
+ (names::a(site.star.name), plain),
+ (format!("· {:.0} ly from here", g.distance(ctx.star, i)), dim),
+ ],
+ width,
+ ));
+ }
+ if let Some(neighbour) = site.neighbour.filter(|_| ctx.visited.contains(&i)) {
+ lines.extend(wrap(
+ label("border"),
+ &[(format!("with {}, who settle here too", neighbour.name()), plain)],
+ width,
+ ));
+ }
+
+ let system = if i == ctx.star { Some(&ctx.system) } else { self.known.as_ref() };
+ if let Some(s) = system {
+ lines.extend(wrap(Vec::new(), &[(site.kind.blurb().to_string(), dim)], width));
+ lines.extend(contents(s, ctx, width));
+ } else if charted[i] {
+ lines.extend(wrap(
+ Vec::new(),
+ &[("What's there, you'll know when you've been. Ask in a tavern.".into(), dim)],
+ width,
+ ));
+ }
+ lines
+ }
+}
+
+/// What's in a system you've been to: its capitals, stations, fuel and
+/// wrecks.
+fn contents(s: &System, ctx: &Ctx, width: usize) -> Vec<Line<'static>> {
+ let plain = Style::new();
+ let dim = Style::new().fg(DIM);
+ let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)];
+ let mut lines = Vec::new();
+ let capitals: Vec<String> = (0..s.places.len())
+ .filter(|&p| matches!(s.landing(p), Landing::Capital(_)))
+ .map(|p| s.places[p].name.clone())
+ .collect();
+ let worlds = s.planets.len();
+ let mut parts = vec![(plural(worlds as u32, "world"), plain)];
+ match capitals.len() {
+ 0 => parts.push(("· no capital".into(), dim)),
+ _ => parts.push((
+ format!("· ⌂ {}", join_names(&capitals)),
+ Style::new().fg(Color::Rgb(130, 214, 140)),
+ )),
+ }
+ lines.extend(wrap(label("worlds"), &parts, width));
+ if !s.stations.is_empty() {
+ let names: Vec<String> = s.stations.iter().map(|st| st.name.clone()).collect();
+ lines.extend(wrap(label("docks"), &[(join_names(&names), plain)], width));
+ }
+ let fuel = if s.sells_fuel() {
+ ("for sale".to_string(), Style::new().fg(FUEL))
+ } else {
+ ("none for sale".to_string(), Style::new().fg(NO_FUEL))
+ };
+ lines.extend(wrap(label("fuel"), &[fuel], width));
+ let beacons = s.beacons(ctx.day, &ctx.stripped);
+ if !beacons.is_empty() {
+ let failing = beacons.iter().filter(|w| w.leaves - ctx.day <= FAILING).count();
+ let mut parts = vec![(
+ format!("{} calling", plural(beacons.len() as u32, "beacon")),
+ Style::new().fg(Color::Rgb(255, 120, 100)),
+ )];
+ if failing > 0 {
+ parts.push((format!("· {failing} failing"), Style::new().fg(FAINT_WRECK)));
+ }
+ lines.extend(wrap(label("wrecks"), &parts, width));
+ }
+ if s.belt.density > 1.0 {
+ lines.extend(wrap(
+ label("belt"),
+ &[(format!("{}: dense, and rich in ore", s.belt.name), plain)],
+ width,
+ ));
+ }
+ lines
+}
+
+/// The whole galaxy, small, in the map's top-left corner: each people's
+/// space, where you are, the choice, and the part the map shows.
+fn draw_inset(buf: &mut Buffer, map: Rect, view: &View, ctx: &Ctx, selected: usize) {
+ let g = &ctx.galaxy;
+ let (w, h) = INSET;
+ let outer = Rect::new(map.x, map.y, w + 2, h + 2);
+ let block = Block::bordered()
+ .title(" galaxy ")
+ .title_style(Style::new().fg(DIM))
+ .border_style(Style::new().fg(DIM));
+ let inner = block.inner(outer);
+ ratatui::widgets::Clear.render(outer, buf);
+ block.render(outer, buf);
+ let (low, high) = g.bounds();
+ let margin = 6.0;
+ let (low, high) = (low - Vec2::new(margin, margin), high + Vec2::new(margin, margin));
+ let to_cell = |p: Vec2| {
+ let col = ((p.x - low.x) / (high.x - low.x) * f64::from(w)).floor();
+ let row = ((high.y - p.y) / (high.y - low.y) * f64::from(h)).floor();
+ (col.clamp(0.0, f64::from(w - 1)) as u16, row.clamp(0.0, f64::from(h - 1)) as u16)
+ };
+ for row in 0..h {
+ for col in 0..w {
+ let p = Vec2::new(
+ low.x + (f64::from(col) + 0.5) / f64::from(w) * (high.x - low.x),
+ high.y - (f64::from(row) + 0.5) / f64::from(h) * (high.y - low.y),
+ );
+ let cell = &mut buf[(inner.x + col, inner.y + row)];
+ cell.set_symbol(" ");
+ if let Some((c, _)) = territory(g, p) {
+ cell.set_bg(sky::shade(people_rgb(c), 0.28));
+ }
+ }
+ }
+ // The view, as corners.
+ let (a, b) =
+ (to_cell(Vec2::new(view.left, view.top)), to_cell(Vec2::new(view.right(), view.bottom())));
+ for (x, y, ch) in [(a.0, a.1, "┌"), (b.0, a.1, "┐"), (a.0, b.1, "└"), (b.0, b.1, "┘")] {
+ buf[(inner.x + x, inner.y + y)].set_symbol(ch).set_fg(Color::Rgb(200, 204, 216));
+ }
+ let (x, y) = to_cell(g.sites[selected].pos);
+ buf[(inner.x + x, inner.y + y)].set_symbol("+").set_style(Style::new().fg(ACCENT).bold());
+ let (x, y) = to_cell(g.sites[ctx.star].pos);
+ buf[(inner.x + x, inner.y + y)].set_symbol("@").set_style(Style::new().fg(Color::White).bold());
+}
+
+/// "1 jump", "3 jumps".
+fn plural(n: u32, what: &str) -> String {
+ if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") }
+}
+
+fn days_text(days: f64) -> String {
+ format!("{days:.1} days")
+}
+
+/// "A", "A and B", "A, B and C".
+fn join_names(names: &[String]) -> String {
+ match names {
+ [] => 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())
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::scenes::tests::test_ctx;
+
+ fn press(map: &mut GalaxyMap, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ #[test]
+ fn jumping_along_a_lane_costs_fuel_and_days() {
+ let mut ctx = test_ctx();
+ let mut map = GalaxyMap::new(&ctx);
+ let start = ctx.star;
+ press(&mut map, &mut ctx, KeyCode::Tab);
+ let to = map.selected;
+ assert!(ctx.galaxy.sites[start].lanes.contains(&to));
+ let days = ctx.galaxy.jump_days(start, to);
+ ctx.departed = Some(0.0);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop));
+ assert_eq!(ctx.star, to);
+ assert_eq!(ctx.departed, None, "no wreck here waits for you");
+ assert_eq!(ctx.system.name, ctx.galaxy.sites[to].name);
+ assert_eq!(ctx.location, ctx.system.edge_place(), "you come out at the edge");
+ assert_eq!(ctx.hold.fuel, crate::app::Hold::BASE_FUEL - 1);
+ assert!((ctx.day - days).abs() < 1e-9);
+ assert!(ctx.visited.contains(&to));
+ assert!(ctx.arrival_note.as_ref().unwrap().contains("1 jump and"));
+ }
+
+ #[test]
+ fn you_can_only_plot_through_charted_space_and_on_the_fuel_you_have() {
+ let mut ctx = test_ctx();
+ let g = &ctx.galaxy;
+ let charted = g.charted(&ctx.visited);
+ let far = (0..g.sites.len()).find(|&i| !charted[i]).unwrap();
+ let mut map = GalaxyMap::new(&ctx);
+ map.select(far, &ctx);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None));
+ assert!(map.report.as_ref().unwrap().contains("uncharted"));
+
+ let next = ctx.galaxy.sites[ctx.star].lanes[0];
+ map.select(next, &ctx);
+ ctx.hold.fuel = 0;
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None));
+ assert!(map.report.as_ref().unwrap().contains("dry"));
+ assert_eq!(ctx.day, 0.0);
+ }
+
+ #[test]
+ fn stranded_ships_are_towed_and_owe_for_it() {
+ let mut ctx = test_ctx();
+ let g = &ctx.galaxy;
+ let dead = (0..g.sites.len()).find(|&i| !g.sites[i].kind.sells_fuel()).unwrap();
+ ctx.enter_system(dead);
+ ctx.hold.fuel = 0;
+ ctx.hold.scrip = 20;
+ assert!(ctx.stranded());
+ let mut map = GalaxyMap::new(&ctx);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::Pop));
+ assert!(ctx.system.sells_fuel());
+ assert!(ctx.system.sells_fuel_at(ctx.location), "dropped where fuel's sold");
+ assert_eq!(ctx.hold.scrip, 0);
+ assert!(ctx.hold.debt > 0);
+ assert!(ctx.day > 0.0);
+ assert!(!ctx.stranded());
+ // No tow for a ship that can buy fuel where it is.
+ let mut map = GalaxyMap::new(&ctx);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::None));
+ }
+
+ #[test]
+ fn arrows_move_between_stars() {
+ let ctx = test_ctx();
+ let mut map = GalaxyMap::new(&ctx);
+ let from = ctx.galaxy.sites[map.selected].pos;
+ map.step(Vec2::new(1.0, 0.0), &ctx);
+ if map.selected != ctx.star {
+ assert!(ctx.galaxy.sites[map.selected].pos.x > from.x);
+ }
+ map.select(ctx.star, &ctx);
+ assert_eq!(map.selected, ctx.star);
+ }
+
+ #[test]
+ fn names_join_naturally() {
+ let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
+ assert_eq!(join_names(&names(&["A"])), "A");
+ assert_eq!(join_names(&names(&["A", "B", "C"])), "A, B and C");
+ assert_eq!(plural(1, "jump"), "1 jump");
+ }
+}
diff --git a/src/scenes/inventory.rs b/src/scenes/inventory.rs
new file mode 100644
index 0000000..d9dd249
--- /dev/null
+++ b/src/scenes/inventory.rs
@@ -0,0 +1,1808 @@
+//! The inventory, after Cataclysm's: everything on you, grouped by what it
+//! is, with all about whatever's picked out; and beside it, when there is
+//! one, another pile to move things to and from: your ship's lockers, what's
+//! on the floor where you stand, or an armourer's rack to buy from and sell
+//! to, with your armour to mend.
+//!
+//! Vim keys throughout: `j` `k` move, `gg` `G` go to the top and bottom,
+//! ctrl-d and ctrl-u by half a page, `h` `l` between the lists, `/` finds.
+//! `o` sorts both lists by something else (`Sort`), within each kind of
+//! thing, and `O` turns the sort round. Every row shows what it weighs and
+//! what it would fetch.
+//! A count before a key does it that many times: `3d` drops three, `D` the
+//! whole stack. `enter` lists everything you can do with what's picked out.
+//!
+//! It isn't a scene of its own. The scene you're walking about in opens it
+//! over itself and carries out what it asks for (`Command`), since only
+//! that scene knows what putting something down means there, and whether
+//! it takes a turn.
+
+use std::cell::Cell;
+use std::collections::BTreeSet;
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap};
+use serde::{Deserialize, Serialize};
+
+use super::keys;
+use super::sky::rgb;
+use crate::dungeon::armour::Armor;
+use crate::dungeon::arms::{Condition, Dmg, Weapon};
+use crate::dungeon::crawl::{Affinity, MEDPATCH_HEAL, MONSTERS};
+use crate::items::{self, Category, Gear, Item, LOCKER_CAPACITY, Slot, Spot, Stack};
+use crate::space::system::Rgb;
+use crate::trade::{self, Terms};
+
+const ACCENT: Color = Color::Rgb(240, 200, 110);
+/// What something would fetch somewhere else: about this.
+const GUESS: Color = Color::Rgb(184, 166, 120);
+const DIM: Color = Color::Rgb(120, 124, 142);
+const HEADING: Color = Color::Rgb(160, 166, 190);
+/// Wider than this, the details sit beside the lists rather than below.
+const WIDE: u16 = 110;
+/// A list narrower than this shows what things fetch, but not what they
+/// weigh.
+const NARROW: usize = 30;
+/// How far in things sit from the heading they're under.
+const INDENT: &str = " ";
+const DETAILS_HEIGHT: u16 = 13;
+
+/// How something looks lying about: a glyph and a colour.
+pub fn look(item: &Item) -> (char, Rgb) {
+ match item {
+ Item::Weapon(w) => (if w.is_gun() { '}' } else { ')' }, w.dmg.rgb()),
+ Item::Armor(_) => ('[', (170, 182, 204)),
+ Item::Food(_) => ('%', (214, 176, 112)),
+ Item::Medpatch => ('!', (240, 96, 96)),
+ Item::Artifact(_) => ('*', (255, 214, 92)),
+ Item::Salvage(_) => ('(', (150, 200, 222)),
+ }
+}
+
+/// The pile beside you.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Beside {
+ /// Your ship's lockers.
+ Lockers,
+ /// The floor where you stand: "on the floor", "on the deck".
+ Floor(&'static str),
+ /// An armourer's rack: what's for sale (see `View::shop`).
+ Rack,
+}
+
+/// The armourer you're trading with, whose rack is the pile beside you.
+#[derive(Clone, Copy)]
+pub struct Counter<'a> {
+ pub terms: Terms,
+ /// What's in your ship's lockers: they'll buy from there too.
+ pub aboard: &'a [Stack],
+ pub scrip: u32,
+}
+
+/// What the lists are sorted by, within each kind of thing.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SortBy {
+ #[default]
+ Name,
+ Weight,
+ /// What it fetches, or here costs.
+ Price,
+ /// Price for its weight.
+ Density,
+ /// Armour, most worn first; the rest after.
+ Durability,
+ /// Weapons by their average damage, armour by what it guards.
+ Strength,
+ /// The order you came by things in.
+ Newest,
+}
+
+impl SortBy {
+ const ALL: [SortBy; 7] = [
+ SortBy::Name,
+ SortBy::Weight,
+ SortBy::Price,
+ SortBy::Density,
+ SortBy::Durability,
+ SortBy::Strength,
+ SortBy::Newest,
+ ];
+
+ /// The sort after this one, round to the first.
+ fn next(self) -> SortBy {
+ let i = SortBy::ALL.iter().position(|&s| s == self).unwrap_or(0);
+ SortBy::ALL[(i + 1) % SortBy::ALL.len()]
+ }
+}
+
+/// How the lists are sorted: by what, and which way round.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Sort {
+ pub by: SortBy,
+ /// The other way round from usual: names z–a, the lightest first, the
+ /// cheapest first...
+ #[serde(default)]
+ pub reversed: bool,
+}
+
+impl Sort {
+ /// How it reads: "heaviest first", "by name".
+ pub fn says(self) -> &'static str {
+ match (self.by, self.reversed) {
+ (SortBy::Name, false) => "by name",
+ (SortBy::Name, true) => "by name, z–a",
+ (SortBy::Weight, false) => "heaviest first",
+ (SortBy::Weight, true) => "lightest first",
+ (SortBy::Price, false) => "dearest first",
+ (SortBy::Price, true) => "cheapest first",
+ (SortBy::Density, false) => "most ¤/kg first",
+ (SortBy::Density, true) => "least ¤/kg first",
+ (SortBy::Durability, false) => "most worn first",
+ (SortBy::Durability, true) => "least worn first",
+ (SortBy::Strength, false) => "strongest first",
+ (SortBy::Strength, true) => "weakest first",
+ (SortBy::Newest, false) => "newest first",
+ (SortBy::Newest, true) => "oldest first",
+ }
+ }
+}
+
+/// What something would fetch, or cost, as a row shows it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Price {
+ /// What the armourer you're at asks or pays, and whether you can pay.
+ Here(u32, bool),
+ /// About what a buyer elsewhere would pay.
+ About(u32),
+ /// A scholar has yet to tell you what it is.
+ Unknown,
+ /// No one buys it.
+ Nothing,
+}
+
+impl Price {
+ fn amount(self) -> Option<u32> {
+ match self {
+ Price::Here(n, _) | Price::About(n) => Some(n),
+ Price::Unknown | Price::Nothing => None,
+ }
+ }
+}
+
+/// What a scene shows the inventory, each time it asks it anything.
+#[derive(Clone, Copy)]
+pub struct View<'a> {
+ pub gear: &'a Gear,
+ /// The pile beside you, if there's one, and what's in it.
+ pub beside: Option<(Beside, &'a [Stack])>,
+ /// How you know monsters take each kind of damage.
+ pub known: &'a BTreeSet<(&'static str, Dmg)>,
+ /// Whether you can be hurt here, and so use a medpatch: only in a wreck.
+ pub hurt: bool,
+ /// The shop you're trading with, if you're at an armourer's.
+ pub shop: Option<Counter<'a>>,
+ /// How the lists are sorted.
+ pub sort: Sort,
+}
+
+/// What the inventory asks the scene to do.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Command {
+ /// Ready the weapon at this index in your pack.
+ Ready(usize),
+ /// Put on the armour at this index in your pack.
+ Wear(usize),
+ TakeOff(Slot),
+ /// Eat one of the food at this index in your pack.
+ Eat(usize),
+ UseMedpatch,
+ /// Put down this many of what's at a spot on you, where you stand.
+ Drop(Spot, u32),
+ /// Put this many of what's at a spot on you in the lockers.
+ Stow(Spot, u32),
+ /// Take this many of the `n`th stack in the pile beside you.
+ Fetch(usize, u32),
+ /// Buy the `n`th thing on the rack.
+ Buy(usize),
+ /// Sell what's at a spot on you.
+ Sell(Spot),
+ /// Sell the `n`th stack in your ship's lockers.
+ SellAboard(usize),
+ /// Have the armour at a spot on you mended.
+ Mend(Spot),
+ /// Have the armour that's the `n`th stack in your ship's lockers mended.
+ MendAboard(usize),
+ /// Have all your armour mended, on you and aboard.
+ MendAll,
+}
+
+/// How the inventory answers a key.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Reply {
+ Stay,
+ Close,
+ Do(Command),
+ /// Sort the lists this way from now on. Takes no time.
+ Sort(Sort),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Row {
+ Heading(&'static str),
+ /// Something on you, or a slot to wear something in with nothing in it.
+ You(Spot),
+ /// The `n`th stack in the pile beside you.
+ Beside(usize),
+ /// The `n`th stack in your ship's lockers, when you're selling.
+ Aboard(usize),
+}
+
+/// One thing you can do with what's picked out.
+struct Act {
+ key: char,
+ what: String,
+ command: Command,
+}
+
+pub struct Inventory {
+ /// Whether the cursor is in the pile beside you, rather than on you.
+ on_beside: bool,
+ /// Where the cursor is in each list, counting only rows you can pick.
+ cursor: [usize; 2],
+ /// A count typed before a key.
+ count: Option<u32>,
+ /// `g` pressed once, waiting for a second.
+ g: bool,
+ /// Typing what to find, after `/`.
+ finding: bool,
+ filter: String,
+ /// Picking from what you can do with what's picked out, on `enter`.
+ menu: Option<usize>,
+ /// What happened last.
+ pub note: Option<String>,
+ /// How many rows the lists showed when last drawn, for paging.
+ page: Cell<usize>,
+}
+
+impl Default for Inventory {
+ fn default() -> Inventory {
+ Inventory::new()
+ }
+}
+
+impl Inventory {
+ pub fn new() -> Inventory {
+ Inventory {
+ on_beside: false,
+ cursor: [0, 0],
+ count: None,
+ g: false,
+ finding: false,
+ filter: String::new(),
+ menu: None,
+ note: None,
+ page: Cell::new(10),
+ }
+ }
+
+ /// Opens with the cursor on the pile beside you: to pick things up.
+ pub fn beside() -> Inventory {
+ Inventory { on_beside: true, ..Inventory::new() }
+ }
+
+ fn matches(&self, item: &Item) -> bool {
+ self.filter.is_empty()
+ || item.label().to_lowercase().contains(&self.filter.to_lowercase())
+ || item.category().heading().to_lowercase().contains(&self.filter.to_lowercase())
+ }
+
+ /// The rows of one list, headings and all.
+ fn rows(&self, view: &View, beside: bool) -> Vec<Row> {
+ let mut rows = Vec::new();
+ let pile: Vec<(Row, &Stack)> = if beside {
+ let Some((_, stacks)) = view.beside else { return rows };
+ stacks.iter().enumerate().map(|(i, s)| (Row::Beside(i), s)).collect()
+ } else {
+ let g = view.gear;
+ let mut worn = Vec::new();
+ for slot in Slot::ALL {
+ match g.worn.get(&slot) {
+ Some(a) if self.matches(&Item::Armor(a.clone())) => {
+ worn.push(Row::You(Spot::Worn(slot)));
+ }
+ None if self.filter.is_empty() => worn.push(Row::You(Spot::Worn(slot))),
+ _ => {}
+ }
+ }
+ if !worn.is_empty() {
+ rows.push(Row::Heading("WORN"));
+ rows.extend(worn);
+ }
+ let ready: Vec<Row> = [(Spot::Melee, &g.melee), (Spot::Gun, &g.gun)]
+ .into_iter()
+ .filter(|(_, w)| self.matches(&Item::Weapon((*w).clone())))
+ .map(|(spot, _)| Row::You(spot))
+ .collect();
+ if !ready.is_empty() {
+ rows.push(Row::Heading("READY"));
+ rows.extend(ready);
+ }
+ g.pack.iter().enumerate().map(|(i, s)| (Row::You(Spot::Pack(i)), s)).collect()
+ };
+ for category in Category::ALL {
+ let mut these: Vec<&(Row, &Stack)> = pile
+ .iter()
+ .filter(|(_, s)| s.item.category() == category && self.matches(&s.item))
+ .collect();
+ if these.is_empty() {
+ continue;
+ }
+ these.sort_by(|a, b| order(view, a, b));
+ rows.push(Row::Heading(category.heading()));
+ rows.extend(these.into_iter().map(|(row, _)| *row));
+ }
+ if !beside && let Some(shop) = &view.shop {
+ let mut aboard: Vec<(Row, &Stack)> = shop
+ .aboard
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| shop.terms.offer(&s.item).is_some() && self.matches(&s.item))
+ .map(|(i, s)| (Row::Aboard(i), s))
+ .collect();
+ aboard.sort_by(|a, b| order(view, a, b));
+ let aboard: Vec<Row> = aboard.into_iter().map(|(row, _)| row).collect();
+ if !aboard.is_empty() {
+ rows.push(Row::Heading("IN YOUR SHIP"));
+ rows.extend(aboard);
+ }
+ }
+ rows
+ }
+
+ /// The rows you can pick in one list.
+ fn picks(&self, view: &View, beside: bool) -> Vec<Row> {
+ self.rows(view, beside).into_iter().filter(|r| !matches!(r, Row::Heading(_))).collect()
+ }
+
+ fn side(&self, view: &View) -> bool {
+ self.on_beside && view.beside.is_some()
+ }
+
+ /// The row picked out, if there's anything to pick.
+ fn picked(&self, view: &View) -> Option<Row> {
+ let side = self.side(view);
+ let picks = self.picks(view, side);
+ let i = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1));
+ picks.get(i).copied()
+ }
+
+ fn stack(view: &View, row: Row) -> Option<Stack> {
+ match row {
+ Row::Heading(_) => None,
+ Row::You(spot) => view.gear.get(spot),
+ Row::Beside(i) => view.beside.and_then(|(_, s)| s.get(i)).cloned(),
+ Row::Aboard(i) => view.shop.as_ref().and_then(|s| s.aboard.get(i)).cloned(),
+ }
+ }
+
+ /// Everything you can do with what's picked out, with `n` as the count.
+ fn acts(view: &View, row: Row, n: u32) -> Vec<Act> {
+ let mut acts = Vec::new();
+ let Some(stack) = Self::stack(view, row) else { return acts };
+ let mut act = |key, what: String, command| acts.push(Act { key, what, command });
+ let some = n.clamp(1, stack.count);
+ let how_many = |verb: &str| match (stack.count, some) {
+ (1, _) => format!("{verb} it"),
+ (_, 1) => format!("{verb} one"),
+ (_, n) => format!("{verb} {n}"),
+ };
+ let offer = view.shop.as_ref().and_then(|s| s.terms.offer(&stack.item));
+ match row {
+ Row::Heading(_) => {}
+ Row::You(spot) => {
+ match (spot, &stack.item) {
+ (Spot::Pack(i), Item::Weapon(_)) => {
+ act('w', "ready it".into(), Command::Ready(i))
+ }
+ (Spot::Pack(i), Item::Armor(_)) => act('W', "wear it".into(), Command::Wear(i)),
+ (Spot::Worn(slot), _) => {
+ act('T', "take it off".into(), Command::TakeOff(slot));
+ }
+ (Spot::Pack(i), Item::Food(_)) => act('E', "eat one".into(), Command::Eat(i)),
+ (Spot::Pack(_), Item::Medpatch) if view.hurt => {
+ act('a', "use one".into(), Command::UseMedpatch);
+ }
+ _ => {}
+ }
+ // A ready weapon goes only if there's another to take its place.
+ let spared = match spot {
+ Spot::Melee => view.gear.spare(false).is_some(),
+ Spot::Gun => view.gear.spare(true).is_some(),
+ _ => true,
+ };
+ if let Some(price) = offer.filter(|_| spared) {
+ act('s', format!("sell it for {price} ¤"), Command::Sell(spot));
+ }
+ if let Some(cost) = mend_cost(view, &stack.item) {
+ act('r', format!("mend it for {cost} ¤"), Command::Mend(spot));
+ }
+ if spared {
+ act('d', how_many("drop"), Command::Drop(spot, some));
+ if stack.count > 1 {
+ act('D', "drop them all".into(), Command::Drop(spot, stack.count));
+ }
+ if let Some((Beside::Lockers, _)) = view.beside {
+ act('m', how_many("stow"), Command::Stow(spot, some));
+ if stack.count > 1 {
+ act('M', "stow them all".into(), Command::Stow(spot, stack.count));
+ }
+ }
+ }
+ }
+ Row::Beside(i) => match (view.beside, &view.shop) {
+ (Some((Beside::Rack, _)), Some(shop)) => {
+ if let Some(price) = shop.terms.asking(&stack.item) {
+ act('b', format!("buy it for {price} ¤"), Command::Buy(i));
+ }
+ }
+ _ => {
+ act('m', how_many("take"), Command::Fetch(i, some));
+ if stack.count > 1 {
+ act('M', "take them all".into(), Command::Fetch(i, stack.count));
+ }
+ }
+ },
+ Row::Aboard(i) => {
+ if let Some(price) = offer {
+ act('s', format!("sell it for {price} ¤"), Command::SellAboard(i));
+ }
+ if let Some(cost) = mend_cost(view, &stack.item) {
+ act('r', format!("mend it for {cost} ¤"), Command::MendAboard(i));
+ }
+ }
+ }
+ if !matches!(row, Row::Beside(_))
+ && let Some((n, cost)) = mend_all_cost(view)
+ && n > 1
+ && matches!(stack.item, Item::Armor(_))
+ {
+ act('R', format!("mend all {n} for {cost} ¤"), Command::MendAll);
+ }
+ acts
+ }
+
+ fn move_by(&mut self, view: &View, by: isize) {
+ let side = self.side(view);
+ let last = self.picks(view, side).len().saturating_sub(1);
+ let cursor = &mut self.cursor[usize::from(side)];
+ *cursor = (*cursor).min(last).saturating_add_signed(by).min(last);
+ }
+
+ pub fn key(&mut self, key: KeyEvent, view: &View) -> Reply {
+ if self.finding {
+ match key.code {
+ KeyCode::Enter => self.finding = false,
+ KeyCode::Esc => {
+ self.finding = false;
+ self.filter.clear();
+ }
+ KeyCode::Backspace if self.filter.is_empty() => self.finding = false,
+ KeyCode::Backspace => {
+ self.filter.pop();
+ }
+ KeyCode::Char(c) => self.filter.push(c),
+ _ => {}
+ }
+ self.cursor = [0, 0];
+ return Reply::Stay;
+ }
+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
+ if let Some(choice) = self.menu {
+ let Some(row) = self.picked(view) else {
+ self.menu = None;
+ return Reply::Stay;
+ };
+ let acts = Self::acts(view, row, self.count.unwrap_or(1));
+ let last = acts.len().saturating_sub(1);
+ let chosen = match key.code {
+ KeyCode::Down | KeyCode::Char('j') => {
+ self.menu = Some((choice + 1).min(last));
+ None
+ }
+ KeyCode::Up | KeyCode::Char('k') => {
+ self.menu = Some(choice.saturating_sub(1));
+ None
+ }
+ KeyCode::Enter | KeyCode::Char('l') => acts.get(choice),
+ KeyCode::Esc | KeyCode::Char('q' | 'h') => {
+ self.menu = None;
+ None
+ }
+ KeyCode::Char(c) => acts.iter().find(|a| a.key == c),
+ _ => None,
+ };
+ return match chosen {
+ Some(act) => {
+ self.menu = None;
+ self.count = None;
+ Reply::Do(act.command)
+ }
+ None => Reply::Stay,
+ };
+ }
+ if let KeyCode::Char(c @ '0'..='9') = key.code
+ && !ctrl
+ && (c != '0' || self.count.is_some())
+ {
+ let digit = c.to_digit(10).unwrap_or(0);
+ self.count = Some((self.count.unwrap_or(0) * 10 + digit).min(9999));
+ return Reply::Stay;
+ }
+ let count = self.count.take();
+ let n = count.unwrap_or(1);
+ let twice = std::mem::take(&mut self.g);
+ let half = (self.page.get() / 2).max(1) as isize;
+ let side = self.side(view);
+ match key.code {
+ KeyCode::Char('d') if ctrl => self.move_by(view, half),
+ KeyCode::Char('u') if ctrl => self.move_by(view, -half),
+ KeyCode::Char('f') if ctrl => self.move_by(view, half * 2),
+ KeyCode::Char('b') if ctrl => self.move_by(view, -half * 2),
+ KeyCode::PageDown => self.move_by(view, half * 2),
+ KeyCode::PageUp => self.move_by(view, -half * 2),
+ KeyCode::Down | KeyCode::Char('j') => self.move_by(view, n as isize),
+ KeyCode::Up | KeyCode::Char('k') => self.move_by(view, -(n as isize)),
+ KeyCode::Home => self.cursor[usize::from(side)] = 0,
+ KeyCode::Char('g') if twice => self.cursor[usize::from(side)] = 0,
+ KeyCode::Char('g') => self.g = true,
+ KeyCode::End | KeyCode::Char('G') => {
+ let last = self.picks(view, side).len().saturating_sub(1);
+ let to = count.map_or(last, |n| (n as usize).saturating_sub(1).min(last));
+ self.cursor[usize::from(side)] = to;
+ }
+ KeyCode::Left | KeyCode::Char('h') => self.on_beside = false,
+ KeyCode::Right | KeyCode::Char('l') => self.on_beside = view.beside.is_some(),
+ KeyCode::Tab | KeyCode::BackTab => self.on_beside = !side && view.beside.is_some(),
+ KeyCode::Char('/') => {
+ self.finding = true;
+ self.filter.clear();
+ self.cursor = [0, 0];
+ }
+ KeyCode::Esc if !self.filter.is_empty() => {
+ self.filter.clear();
+ self.cursor = [0, 0];
+ }
+ KeyCode::Esc | KeyCode::Char('q' | 'i') => return Reply::Close,
+ KeyCode::Char('o') => {
+ return self.sort(view, Sort { by: view.sort.by.next(), reversed: false });
+ }
+ KeyCode::Char('O') => {
+ return self.sort(view, Sort { reversed: !view.sort.reversed, ..view.sort });
+ }
+ KeyCode::Enter => {
+ if let Some(row) = self.picked(view)
+ && !Self::acts(view, row, n).is_empty()
+ {
+ self.menu = Some(0);
+ self.count = count;
+ }
+ }
+ KeyCode::Char(c) => {
+ if let Some(row) = self.picked(view)
+ && let Some(act) = Self::acts(view, row, n).into_iter().find(|a| a.key == c)
+ {
+ return Reply::Do(act.command);
+ }
+ // Wherever the cursor is, at an armourer's.
+ if c == 'R' && view.shop.is_some() {
+ return Reply::Do(Command::MendAll);
+ }
+ }
+ _ => {}
+ }
+ Reply::Stay
+ }
+
+ /// Sorts the lists another way, keeping the cursor on what it was on.
+ fn sort(&mut self, view: &View, sort: Sort) -> Reply {
+ let sorted = View { sort, ..*view };
+ for side in [false, true] {
+ let picks = self.picks(view, side);
+ let at = self.cursor[usize::from(side)].min(picks.len().saturating_sub(1));
+ if let Some(row) = picks.get(at) {
+ let now = self.picks(&sorted, side).iter().position(|r| r == row);
+ self.cursor[usize::from(side)] = now.unwrap_or(0);
+ }
+ }
+ Reply::Sort(sort)
+ }
+
+ /// Draws the inventory over the whole of `area`.
+ pub fn render(&self, frame: &mut Frame, area: Rect, view: &View) {
+ frame.render_widget(Clear, area);
+ let [body, foot] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(area);
+ let wide = body.width >= WIDE;
+ // Below the lists, on a short screen the details give up room first.
+ let details_height = (body.height * 2 / 5).clamp(6, DETAILS_HEIGHT);
+ let (you, beside, details) = match view.beside {
+ Some(_) if wide => {
+ let [a, b, c] = Layout::horizontal([Constraint::Fill(1); 3]).areas(body);
+ (a, Some(b), c)
+ }
+ Some(_) => {
+ let [lists, details] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)])
+ .areas(body);
+ let [a, b] = Layout::horizontal([Constraint::Fill(1); 2]).areas(lists);
+ (a, Some(b), details)
+ }
+ None if body.width >= 72 => {
+ let [a, c] =
+ Layout::horizontal([Constraint::Fill(3), Constraint::Fill(2)]).areas(body);
+ (a, None, c)
+ }
+ None => {
+ let [a, c] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(details_height)])
+ .areas(body);
+ (a, None, c)
+ }
+ };
+ self.draw_list(frame, you, view, false);
+ if let Some(beside) = beside {
+ self.draw_list(frame, beside, view, true);
+ }
+ self.draw_details(frame, details, view);
+ self.draw_foot(frame, foot, view.shop.is_some());
+ }
+
+ fn draw_list(&self, frame: &mut Frame, area: Rect, view: &View, beside: bool) {
+ let focused = self.side(view) == beside;
+ let title = if beside {
+ match view.beside {
+ Some((Beside::Lockers, stacks)) => format!(
+ " LOCKERS · {} / {} kg ",
+ items::kg(items::pile_mass(stacks)),
+ items::kg(LOCKER_CAPACITY)
+ ),
+ Some((Beside::Floor(here), _)) => format!(" {} ", here.to_uppercase()),
+ Some((Beside::Rack, _)) => {
+ format!(" FOR SALE · you have {} ¤ ", view.shop.as_ref().map_or(0, |s| s.scrip))
+ }
+ None => String::new(),
+ }
+ } else {
+ let g = view.gear;
+ format!(
+ " YOU · {} / {} kg · {} ",
+ items::kg(g.carried()),
+ items::kg(g.capacity),
+ view.sort.says()
+ )
+ };
+ let colour = if focused { ACCENT } else { DIM };
+ let block = Block::bordered()
+ .border_style(Style::new().fg(colour))
+ .title(Line::from(title).fg(colour).bold());
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let rows = self.rows(view, beside);
+ let picked = self.picked(view);
+ let height = usize::from(inner.height);
+ if focused {
+ self.page.set(height);
+ }
+ let at = rows.iter().position(|r| Some(*r) == picked && focused).unwrap_or(0);
+ // Keep the picked row in view, with its heading if there's room.
+ let first =
+ at.saturating_sub(height.saturating_sub(1) / 2).min(rows.len().saturating_sub(height));
+ let width = usize::from(inner.width);
+ // Which column is which, over the first heading in sight.
+ let mut labelled = false;
+ let mut lines: Vec<Line> = rows
+ .iter()
+ .skip(first)
+ .take(height)
+ .map(|&row| {
+ let line = match row {
+ Row::Heading(text) => {
+ heading(text, width, !std::mem::replace(&mut labelled, true))
+ }
+ _ => self.row_line(view, row, width),
+ };
+ if Some(row) == picked && focused {
+ line.patch_style(Style::new().add_modifier(Modifier::REVERSED))
+ } else {
+ line
+ }
+ })
+ .collect();
+ if rows.is_empty() {
+ let rack = matches!(view.beside, Some((Beside::Rack, _)));
+ let empty = match (beside, self.filter.is_empty()) {
+ (_, false) => "Nothing like that.",
+ (true, true) if rack => "The rack's bare.",
+ (true, true) => "Nothing here.",
+ (false, true) => "Nothing on you.",
+ };
+ lines.push(Line::from(format!(" {empty}")).fg(DIM));
+ }
+ frame.render_widget(Paragraph::new(lines), inner);
+ }
+
+ fn row_line(&self, view: &View, row: Row, width: usize) -> Line<'static> {
+ let weighed = width >= NARROW;
+ let label = match row {
+ Row::Heading(text) => return heading(text, width, true),
+ Row::You(Spot::Worn(slot)) => format!("{:<6}", slot.name()),
+ Row::You(Spot::Melee) => "melee ".into(),
+ Row::You(Spot::Gun) => "gun ".into(),
+ _ => String::new(),
+ };
+ let Some(stack) = Self::stack(view, row) else {
+ return Line::from(vec![
+ Span::raw(format!("{INDENT} {label}")).fg(DIM),
+ Span::raw("—").fg(DIM),
+ ]);
+ };
+ let (glyph, colour) = look(&stack.item);
+ let mut name = stack.item.label();
+ if stack.count > 1 {
+ name = format!("{name} ×{}", stack.count);
+ }
+ if let Item::Weapon(w) = &stack.item
+ && let Some(g) = w.gun
+ {
+ name = format!("{name} {}/{}", w.loaded, g.magazine);
+ }
+ if let Item::Armor(a) = &stack.item
+ && a.durability < a.max_durability
+ {
+ name = format!("{name} {}/{}", a.durability, a.max_durability);
+ }
+ let (price, price_colour) = match price(view, row, &stack) {
+ Price::Here(n, true) => (n.to_string(), ACCENT),
+ Price::Here(n, false) => (n.to_string(), DIM),
+ Price::About(n) => (format!("~{n}"), GUESS),
+ Price::Unknown => ("?".into(), DIM),
+ Price::Nothing => (String::new(), DIM),
+ };
+ let kg = items::kg(stack.mass());
+ let right = columns(width, &kg, &price);
+ let used = INDENT.len() + 2 + label.chars().count() + 1 + right.chars().count();
+ let room = width.saturating_sub(used);
+ let shown: String = name.chars().take(room).collect();
+ let pad = room.saturating_sub(shown.chars().count());
+ let named = match &stack.item {
+ Item::Weapon(w) => w.named,
+ Item::Armor(a) => a.named,
+ _ => false,
+ };
+ let name_style = Style::new().fg(rgb(colour));
+ let mut spans = vec![
+ Span::raw(INDENT),
+ Span::raw(glyph.to_string()).fg(rgb(colour)),
+ Span::raw(" "),
+ Span::raw(label).fg(DIM),
+ Span::styled(shown, if named { name_style.bold() } else { name_style }),
+ Span::raw(format!("{:pad$} ", "")),
+ ];
+ if weighed {
+ spans.push(Span::raw(format!("{kg:>5} ")).fg(DIM));
+ }
+ spans.push(Span::raw(format!("{price:>5} ")).fg(price_colour));
+ Line::from(spans)
+ }
+
+ fn draw_details(&self, frame: &mut Frame, area: Rect, view: &View) {
+ let picked = self.picked(view);
+ let stack = picked.and_then(|row| Self::stack(view, row));
+ let title = stack.as_ref().map_or(String::new(), |s| format!(" {} ", s.item.label()));
+ let block = Block::bordered()
+ .border_style(Style::new().fg(DIM))
+ .title(Line::from(title).fg(ACCENT).bold())
+ .padding(Padding::horizontal(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let Some(row) = picked else { return };
+ let mut lines = about(view, row, stack.as_ref());
+ let acts = Self::acts(view, row, self.count.unwrap_or(1));
+ if !acts.is_empty() {
+ lines.push(Line::default());
+ match self.menu {
+ Some(choice) => {
+ for (i, act) in acts.iter().enumerate() {
+ let line = Line::from(vec![
+ Span::raw(format!(" {} ", act.key)).fg(ACCENT).bold(),
+ Span::raw(format!(" {} ", act.what)),
+ ]);
+ let chosen = i == choice.min(acts.len() - 1);
+ lines.push(if chosen {
+ line.patch_style(Style::new().add_modifier(Modifier::REVERSED))
+ } else {
+ line
+ });
+ }
+ }
+ None => {
+ let mut spans = Vec::new();
+ for act in &acts {
+ spans.push(Span::raw(act.key.to_string()).fg(ACCENT).bold());
+ spans.push(Span::raw(format!(" {} ", act.what)).fg(DIM));
+ }
+ lines.push(Line::from(spans));
+ }
+ }
+ }
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
+ }
+
+ fn draw_foot(&self, frame: &mut Frame, area: Rect, shop: bool) {
+ let keys: Vec<Span> = if self.finding {
+ let mut spans = vec![
+ Span::raw(format!("/{}", self.filter)).fg(ACCENT),
+ Span::raw("█ ").fg(ACCENT),
+ ];
+ spans.extend(keys::line(&[("enter", "keep"), ("esc", "clear")]).spans);
+ spans
+ } else if self.menu.is_some() {
+ keys::line(&[("j/k", "choose"), ("enter", "do it"), ("esc", "back")]).spans
+ } else {
+ let mut hints = vec![
+ ("j/k", "move"),
+ ("gg/G", "top/end"),
+ ("^d/^u", "page"),
+ ("h/l", "lists"),
+ ("/", "find"),
+ ("o/O", "sort"),
+ ("enter", "act"),
+ ];
+ if shop {
+ hints.push(("b/s", "buy/sell"));
+ hints.push(("r/R", "mend"));
+ }
+ hints.push(("esc", if shop { "step away" } else { "close" }));
+ keys::line(&hints).spans
+ };
+ // What you're finding, a count you've typed and what just happened,
+ // above the keys.
+ let mut top = Vec::new();
+ if !self.filter.is_empty() && !self.finding {
+ top.push(Span::raw(format!(" /{}", self.filter)).fg(ACCENT));
+ }
+ if let Some(n) = self.count {
+ top.push(Span::raw(format!(" {n}…")).fg(ACCENT).bold());
+ }
+ if let Some(note) = &self.note {
+ top.push(Span::raw(format!(" {note}")).fg(ACCENT));
+ }
+ let lines = vec![Line::from(top), Line::from(keys)];
+ frame.render_widget(Paragraph::new(lines), area);
+ }
+}
+
+/// What's on the right of a row: what it weighs, in kg, and what it would
+/// fetch; too narrow for both, only what it'd fetch.
+fn columns(width: usize, kg: &str, price: &str) -> String {
+ if width >= NARROW { format!("{kg:>5} {price:>5} ") } else { format!("{price:>5} ") }
+}
+
+/// A heading over a kind of thing: "WEAPONS", with which column is which
+/// on the right if `units`.
+fn heading(text: &str, width: usize, units: bool) -> Line<'static> {
+ let mut spans = vec![Span::raw(format!(" {text}")).fg(HEADING).bold()];
+ if units {
+ let units = columns(width, "kg", "¤");
+ let pad = width.saturating_sub(1 + text.chars().count() + units.chars().count());
+ spans.push(Span::raw(format!("{:pad$}{units}", "")).fg(DIM));
+ }
+ Line::from(spans)
+}
+
+/// A description as a sentence: "Dense and faintly sweet."
+fn sentence(text: &str) -> String {
+ capitalise(&format!("{}.", text.trim_end_matches('.')))
+}
+
+/// All about what's picked out.
+fn about(view: &View, row: Row, stack: Option<&Stack>) -> Vec<Line<'static>> {
+ let dim = |text: String| Line::from(text).fg(DIM);
+ let Some(stack) = stack else {
+ return match row {
+ Row::You(Spot::Worn(slot)) => vec![dim(format!("Nothing on your {}.", slot.name()))],
+ _ => Vec::new(),
+ };
+ };
+ let mut lines = vec![Line::from(capitalise(&stack.name())).fg(rgb(look(&stack.item).1)).bold()];
+ match &stack.item {
+ Item::Weapon(w) => weapon_lines(view, w, row, &mut lines),
+ Item::Armor(a) => armour_lines(view, a, row, &mut lines),
+ Item::Food(f) => {
+ lines.push(Line::from(sentence(&f.description)).italic().fg(DIM));
+ lines.push(dim("Nothing gets hungry yet.".into()));
+ }
+ Item::Medpatch => {
+ lines.push(Line::from(format!(
+ "Stings, then numbs: heals {MEDPATCH_HEAL} and stops bleeding."
+ )));
+ lines.push(dim("Aboard a wreck, m uses one (or a, here).".into()));
+ }
+ Item::Artifact(a) if a.identified => {
+ lines.push(Line::from(format!("A scholar has told you what it is: {}.", a.true_name)));
+ let value = trade::artifact_value(a);
+ lines.push(dim(format!("A scholar would give you about {value} ¤ for it.")));
+ }
+ Item::Artifact(_) => {
+ lines.push(Line::from("Something strange, from a wreck."));
+ lines.push(dim("A scholar can tell you what it is, and buy it.".into()));
+ }
+ Item::Salvage(s) => {
+ lines.push(Line::from(format!("Worth about {} ¤ to a broker or a station.", s.value)));
+ }
+ }
+ if let Some(shop) = &view.shop {
+ match row {
+ Row::Beside(_) => {
+ if let Some(asking) = shop.terms.asking(&stack.item) {
+ let have = shop.scrip;
+ lines.push(if asking <= have {
+ Line::from(format!("{asking} ¤. You have {have} ¤."))
+ } else {
+ Line::from(format!("{asking} ¤: {} ¤ more than you have.", asking - have))
+ .yellow()
+ });
+ }
+ }
+ _ => {
+ if let Some(offer) = shop.terms.offer(&stack.item) {
+ lines.push(Line::from(format!("They'd give you {offer} ¤ for it.")));
+ let alone = match row {
+ Row::You(Spot::Melee) => view.gear.spare(false).is_none(),
+ Row::You(Spot::Gun) => view.gear.spare(true).is_none(),
+ _ => false,
+ };
+ if alone {
+ lines.push(dim("Have another of its sort to ready first.".into()));
+ }
+ }
+ }
+ }
+ }
+ if let Row::Aboard(_) = row {
+ lines.push(dim("In your ship's lockers.".into()));
+ }
+ let each = items::kg(stack.item.mass());
+ let mut weighs = if stack.count > 1 {
+ format!("{each} kg each, {} kg in all.", items::kg(stack.mass()))
+ } else {
+ format!("{each} kg.")
+ };
+ // Salvage and artifacts say what they're worth already.
+ if view.shop.is_none()
+ && matches!(stack.item, Item::Weapon(_) | Item::Armor(_))
+ && let Some(offer) = trade::typical_offer(&stack.item)
+ {
+ weighs.push_str(&format!(" An armourer would give you about {offer} ¤ for it."));
+ }
+ lines.push(dim(weighs));
+ lines
+}
+
+/// What a row's stack would fetch, or cost: at an armourer's, their price
+/// for what they deal in; anywhere else, or for anything else, about what a
+/// buyer would give.
+fn price(view: &View, row: Row, stack: &Stack) -> Price {
+ if let Some(shop) = &view.shop {
+ let here = match row {
+ Row::Beside(_) => {
+ shop.terms.asking(&stack.item).map(|n| Price::Here(n, n <= shop.scrip))
+ }
+ _ => shop.terms.offer(&stack.item).map(|n| Price::Here(n, true)),
+ };
+ if let Some(here) = here {
+ return here;
+ }
+ }
+ match (&stack.item, trade::typical_offer(&stack.item)) {
+ (_, Some(n)) => Price::About(n.saturating_mul(stack.count)),
+ (Item::Artifact(_), None) => Price::Unknown,
+ _ => Price::Nothing,
+ }
+}
+
+/// Which of two rows of a pile comes first, within their kind, by the
+/// view's sort. Things the sort says nothing about (no price, no
+/// durability) go last whichever way round; ties go by name.
+fn order(view: &View, a: &(Row, &Stack), b: &(Row, &Stack)) -> std::cmp::Ordering {
+ let name = |(_, s): &(Row, &Stack)| s.item.label().to_lowercase();
+ let by_name = name(a).cmp(&name(b));
+ let sort = view.sort;
+ if sort.by == SortBy::Name {
+ return if sort.reversed { by_name.reverse() } else { by_name };
+ }
+ let key = |&(row, s): &(Row, &Stack)| -> Option<f64> {
+ let grams = f64::from(s.mass());
+ match sort.by {
+ SortBy::Name => None,
+ SortBy::Weight => Some(grams),
+ SortBy::Price => price(view, row, s).amount().map(f64::from),
+ SortBy::Density => {
+ let n = price(view, row, s).amount()?;
+ (grams > 0.0).then(|| f64::from(n) / grams)
+ }
+ // Most worn first: least left, as a share of whole.
+ SortBy::Durability => match &s.item {
+ Item::Armor(a) if a.max_durability > 0 => {
+ Some(-f64::from(a.durability) / f64::from(a.max_durability))
+ }
+ _ => None,
+ },
+ SortBy::Strength => match &s.item {
+ Item::Weapon(w) => {
+ let pellets = w.gun.map_or(1, |g| g.pellets);
+ Some(f64::from(w.damage.0 + w.damage.1) / 2.0 * f64::from(pellets))
+ }
+ Item::Armor(a) => {
+ Some(f64::from(Dmg::ALL.iter().map(|&d| a.guard(d)).sum::<i32>()))
+ }
+ _ => None,
+ },
+ SortBy::Newest => match row {
+ Row::You(Spot::Pack(i)) | Row::Beside(i) | Row::Aboard(i) => Some(i as f64),
+ _ => None,
+ },
+ }
+ };
+ let first = match (key(a), key(b)) {
+ (Some(x), Some(y)) => {
+ // Highest first, unless turned round.
+ let o = y.total_cmp(&x);
+ if sort.reversed { o.reverse() } else { o }
+ }
+ (Some(_), None) => std::cmp::Ordering::Less,
+ (None, Some(_)) => std::cmp::Ordering::Greater,
+ (None, None) => std::cmp::Ordering::Equal,
+ };
+ first.then(by_name)
+}
+
+/// What the armourer you're at wants to mend `item`, if it's armour that
+/// needs it.
+fn mend_cost(view: &View, item: &Item) -> Option<u32> {
+ let shop = view.shop.as_ref()?;
+ let Item::Armor(a) = item else { return None };
+ Some(shop.terms.mending(a)).filter(|&cost| cost > 0)
+}
+
+/// How many pieces of yours want mending, on you and aboard, and what the
+/// armourer you're at wants for all of them.
+fn mend_all_cost(view: &View) -> Option<(usize, u32)> {
+ let shop = view.shop.as_ref()?;
+ let g = view.gear;
+ let packed = g.pack.iter().chain(shop.aboard).filter_map(|s| match &s.item {
+ Item::Armor(a) => Some(a),
+ _ => None,
+ });
+ let costs: Vec<u32> =
+ g.worn.values().chain(packed).map(|a| shop.terms.mending(a)).filter(|&c| c > 0).collect();
+ (!costs.is_empty()).then(|| (costs.len(), costs.iter().sum()))
+}
+
+fn armour_lines(view: &View, a: &Armor, row: Row, lines: &mut Vec<Line<'static>>) {
+ let what = capitalise(&a.description);
+ let what = match a.condition {
+ Condition::Worn => Some(format!("{what}, worn: it won't last as long.")),
+ Condition::Sound if a.named => Some(format!("{what}.")),
+ Condition::Sound => None,
+ Condition::Fine => Some(format!("{what}, finely made.")),
+ Condition::Relic => Some(format!("{what}: a relic, and stronger for it.")),
+ };
+ lines.extend(what.map(|what| Line::from(what).italic().fg(DIM)));
+ let on = match row {
+ Row::You(Spot::Worn(_)) => "You wear it",
+ _ => "Worn",
+ };
+ lines.push(Line::from(format!("{on} on your {}. Guards {}.", a.slot.name(), a.guards_text())));
+ let mut durability = capitalise(&a.durability_text());
+ if a.ruined() {
+ durability.push_str(": worn out, it guards nothing until it's mended.");
+ } else if a.thin() {
+ durability.push_str(": below half, it guards 1 less.");
+ } else {
+ durability.push('.');
+ }
+ let line = match mend_cost(view, &Item::Armor(a.clone())) {
+ Some(cost) if !matches!(row, Row::Beside(_)) => Line::from(vec![
+ Span::raw(durability),
+ Span::raw(format!(" Mending it whole: {cost} ¤.")).fg(ACCENT),
+ ]),
+ _ => Line::from(durability),
+ };
+ lines.push(match () {
+ _ if a.ruined() => line.red(),
+ _ if a.thin() => line.yellow(),
+ _ => line,
+ });
+ if !matches!(row, Row::You(Spot::Worn(_))) {
+ lines.push(versus_armour(a, view.gear.worn.get(&a.slot)));
+ }
+ if view.shop.is_none() {
+ lines.push(dim_line("Each blow it softens wears it 1. An armourer mends it."));
+ }
+}
+
+fn dim_line(text: &str) -> Line<'static> {
+ Line::from(text.to_string()).fg(DIM)
+}
+
+/// How `a` measures up against what you wear in its place, as it is now:
+/// "Against your vac-helmet: +1 cut, -1 pierce." Better in green, worse in
+/// yellow.
+fn versus_armour(a: &Armor, worn: Option<&Armor>) -> Line<'static> {
+ let Some(worn) = worn else {
+ return dim_line(&format!("You wear nothing on your {} now.", a.slot.name()));
+ };
+ let mut diffs: Vec<(i32, String)> = Vec::new();
+ for dmg in Dmg::ALL {
+ let d = a.guard(dmg) - worn.guard(dmg);
+ if d != 0 {
+ diffs.push((d, format!("{d:+} {}", dmg.name())));
+ }
+ }
+ match (a.stanching(), worn.stanching()) {
+ (true, false) => diffs.push((1, "stops bleeding".into())),
+ (false, true) => diffs.push((-1, "lets wounds bleed".into())),
+ _ => {}
+ }
+ let kg = |grams: u32| f64::from(grams) / 1000.0;
+ let heavier = kg(a.mass) - kg(worn.mass);
+ if heavier.abs() >= 0.1 {
+ diffs.push((if heavier < 0.0 { 1 } else { -1 }, format!("{heavier:+.1} kg")));
+ }
+ let mut spans = vec![Span::raw(format!("Against {}: ", worn.yours())).fg(DIM)];
+ if diffs.is_empty() {
+ spans.push(Span::raw("much the same.").fg(DIM));
+ }
+ let last = diffs.len().saturating_sub(1);
+ for (i, (sign, text)) in diffs.into_iter().enumerate() {
+ let span = Span::raw(text);
+ spans.push(match sign {
+ s if s > 0 => span.green(),
+ s if s < 0 => span.yellow(),
+ _ => span,
+ });
+ spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM));
+ }
+ Line::from(spans)
+}
+
+fn weapon_lines(view: &View, w: &Weapon, row: Row, lines: &mut Vec<Line<'static>>) {
+ let what = match w.condition {
+ _ if w.kit => {
+ "Your own. Lose everything else aboard a wreck and you keep this.".to_string()
+ }
+ Condition::Worn => format!("{}, worn: it hits softer.", capitalise(&w.description)),
+ Condition::Sound => format!("{}.", capitalise(&w.description)),
+ Condition::Fine => format!("{}, finely made.", capitalise(&w.description)),
+ Condition::Relic => {
+ format!("{}: a relic, and stronger for it.", capitalise(&w.description))
+ }
+ };
+ lines.push(Line::from(what).italic().fg(DIM));
+ match row {
+ Row::You(Spot::Melee) => lines.push(Line::from("Ready: you hit things with it.")),
+ Row::You(Spot::Gun) => lines.push(Line::from("Ready: you fire it.")),
+ _ => {}
+ }
+ lines.extend(w.stats().into_iter().map(Line::from));
+ if !matches!(row, Row::You(Spot::Melee | Spot::Gun)) {
+ let ready = if w.is_gun() { &view.gear.gun } else { &view.gear.melee };
+ lines.push(versus(w, ready));
+ }
+ lines.push(Line::from(w.dmg.about()).fg(DIM));
+ let against = |pick: fn(Affinity) -> bool| {
+ let names: Vec<&str> = MONSTERS
+ .iter()
+ .filter(|k| view.known.contains(&(k.name, w.dmg)) && pick(k.affinity(w.dmg)))
+ .map(|k| k.name)
+ .collect();
+ names.join(", ")
+ };
+ let good = against(|a| a == Affinity::Weak);
+ if !good.is_empty() {
+ lines.push(Line::from(vec!["good against ".green(), Span::from(good)]));
+ }
+ let poor = against(|a| matches!(a, Affinity::Resists | Affinity::Immune));
+ if !poor.is_empty() {
+ lines.push(Line::from(vec!["poor against ".yellow(), Span::from(poor)]));
+ }
+}
+
+/// How `w` measures up against the weapon you have ready in its place:
+/// "Against your needler: +3 damage, +5 range." Better in green, worse in
+/// yellow.
+fn versus(w: &Weapon, ready: &Weapon) -> Line<'static> {
+ let damage = |w: &Weapon| {
+ let (lo, hi) = w.damage;
+ f64::from(lo + hi) / 2.0 * f64::from(w.gun.map_or(1, |g| g.pellets))
+ };
+ let pct = |p: f64| (p * 100.0).round() as i32;
+ let mut diffs: Vec<(f64, String)> = Vec::new();
+ let d = damage(w) - damage(ready);
+ if d.abs() >= 0.25 {
+ let d = (d * 2.0).round() / 2.0;
+ let shown = if d.fract() == 0.0 { format!("{d:+.0}") } else { format!("{d:+.1}") };
+ diffs.push((d, format!("{shown} damage")));
+ }
+ match (w.gun, ready.gun) {
+ (Some(g), Some(r)) => {
+ if g.range != r.range {
+ diffs
+ .push((f64::from(g.range - r.range), format!("{:+} range", g.range - r.range)));
+ }
+ if g.magazine != r.magazine {
+ let n = i64::from(g.magazine) - i64::from(r.magazine);
+ let rounds = if n.abs() == 1 { "round" } else { "rounds" };
+ diffs.push((n as f64, format!("{n:+} {rounds}")));
+ }
+ }
+ _ => {
+ let hit = pct(w.accuracy) - pct(ready.accuracy);
+ if hit != 0 {
+ diffs.push((f64::from(hit), format!("{hit:+}% to hit")));
+ }
+ }
+ }
+ let crit = pct(w.crit) - pct(ready.crit);
+ if crit != 0 {
+ diffs.push((f64::from(crit), format!("{crit:+}% crit")));
+ }
+ if w.dmg != ready.dmg {
+ diffs.push((0.0, format!("{} not {}", w.dmg.name(), ready.dmg.name())));
+ }
+ let mut spans = vec![Span::raw(format!("Against {}: ", ready.yours())).fg(DIM)];
+ if diffs.is_empty() {
+ spans.push(Span::raw("much the same.").fg(DIM));
+ }
+ let last = diffs.len().saturating_sub(1);
+ for (i, (sign, text)) in diffs.into_iter().enumerate() {
+ let span = Span::raw(text);
+ spans.push(match sign {
+ s if s > 0.0 => span.green(),
+ s if s < 0.0 => span.yellow(),
+ _ => span,
+ });
+ spans.push(Span::raw(if i == last { "." } else { ", " }).fg(DIM));
+ }
+ Line::from(spans)
+}
+
+fn capitalise(s: &str) -> String {
+ let mut chars = s.chars();
+ chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect())
+}
+
+/// Carries out a command that takes no time, outside a wreck: on what you
+/// carry, the lockers if they're beside you, and the pile on the floor
+/// where you stand. Says how it went.
+pub fn carry_out(
+ command: Command,
+ gear: &mut Gear,
+ lockers: Option<&mut Vec<Stack>>,
+ floor: &mut Vec<Stack>,
+ beside: Option<Beside>,
+) -> String {
+ let said = |result: Result<String, String>| result.unwrap_or_else(|e| e);
+ match command {
+ Command::Ready(i) => said(gear.ready(i)),
+ Command::Wear(i) => said(gear.wear(i)),
+ Command::TakeOff(slot) => said(gear.take_off(slot)),
+ Command::Eat(i) => said(gear.eat(i)),
+ Command::UseMedpatch => "You're not hurt.".into(),
+ Command::Drop(spot, n) => match gear.remove(spot, n) {
+ Ok(stack) => {
+ let said = format!("You put down {}.", stack.name());
+ items::stow(floor, stack);
+ said
+ }
+ Err(said) => said,
+ },
+ Command::Stow(spot, n) => {
+ let Some(lockers) = lockers else { return "There are no lockers here.".into() };
+ let Some(stack) = gear.get(spot) else { return "There's nothing there.".into() };
+ let n = n.min(stack.count);
+ let room = LOCKER_CAPACITY.saturating_sub(items::pile_mass(lockers));
+ if stack.item.mass().saturating_mul(n) > room {
+ return "The lockers are full.".into();
+ }
+ match gear.remove(spot, n) {
+ Ok(stack) => {
+ let said = format!("You stow {} in the lockers.", stack.name());
+ items::stow(lockers, stack);
+ said
+ }
+ Err(said) => said,
+ }
+ }
+ Command::Fetch(i, n) => {
+ let (pile, from) = match (beside, lockers) {
+ (Some(Beside::Lockers), Some(lockers)) => (lockers, " from the lockers"),
+ _ => (floor, ""),
+ };
+ let Some(stack) = pile.get(i) else { return "There's nothing there.".into() };
+ let n = n.min(stack.count).min(gear.fits(&stack.item));
+ if n == 0 {
+ let room = items::kg(gear.room());
+ return format!("You've no room for {}: only {room} kg to spare.", stack.name());
+ }
+ let stack = items::take(pile, i, n);
+ let said = format!("You take {}{from}.", stack.name());
+ gear.add(stack);
+ said
+ }
+ Command::Buy(_) | Command::Sell(_) | Command::SellAboard(_) => {
+ "There's no one here to trade with.".into()
+ }
+ Command::Mend(_) | Command::MendAboard(_) | Command::MendAll => {
+ "There's no armourer here to mend it.".into()
+ }
+ }
+}
+
+/// Takes the stacks lying at `at` out of a list of piles, to work on.
+pub fn gather<K: PartialEq + Copy>(piles: &mut Vec<(K, Stack)>, at: K) -> Vec<Stack> {
+ let mut here = Vec::new();
+ let mut i = 0;
+ while i < piles.len() {
+ if piles[i].0 == at {
+ here.push(piles.remove(i).1);
+ } else {
+ i += 1;
+ }
+ }
+ here
+}
+
+/// Puts back what `gather` took, after.
+pub fn scatter<K: Copy>(piles: &mut Vec<(K, Stack)>, at: K, here: Vec<Stack>) {
+ piles.extend(here.into_iter().map(|s| (at, s)));
+}
+
+/// What's lying at `at` in a list of piles, for showing.
+pub fn lying<K: PartialEq>(piles: &[(K, Stack)], at: K) -> Vec<Stack> {
+ piles.iter().filter(|(k, _)| *k == at).map(|(_, s)| s.clone()).collect()
+}
+
+/// What to say when you step onto things: "There's a cleaver here (g)."
+pub fn underfoot(here: &[Stack]) -> Option<String> {
+ match here {
+ [] => None,
+ [one] => Some(format!("There's {} here (g picks it up).", one.name())),
+ _ => Some(format!("There are {} things here (g to look).", here.len())),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dungeon::{Pos, arms};
+ use crate::names::Culture;
+ use crate::seed;
+
+ fn press(inv: &mut Inventory, view: &View, code: KeyCode) -> Reply {
+ inv.key(KeyEvent::new(code, KeyModifiers::NONE), view)
+ }
+
+ fn typed(inv: &mut Inventory, view: &View, keys: &str) -> Vec<Reply> {
+ keys.chars().map(|c| press(inv, view, KeyCode::Char(c))).collect()
+ }
+
+ fn gear() -> Gear {
+ let mut gear = Gear::default();
+ gear.add(Stack { item: Item::Medpatch, count: 5 });
+ let mut rng = seed::rng(2);
+ gear.add(Stack::one(Item::Weapon(arms::find(&mut rng, arms::Source::Crew, false))));
+ gear
+ }
+
+ #[test]
+ fn vim_keys_move_and_counts_count() {
+ let gear = gear();
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ // Worn ×4, ready ×2, then the pack: the torch is ready, so the pack
+ // has the found weapon and the medpatches.
+ assert_eq!(inv.picks(&view, false).len(), 8);
+ typed(&mut inv, &view, "G");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(0))), "medpatches come last");
+ typed(&mut inv, &view, "gg");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Worn(Slot::Head))));
+ typed(&mut inv, &view, "5j");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Gun)));
+ typed(&mut inv, &view, "G");
+ let replies = typed(&mut inv, &view, "3d");
+ assert_eq!(replies[1], Reply::Do(Command::Drop(Spot::Pack(0), 3)));
+ assert_eq!(typed(&mut inv, &view, "D")[0], Reply::Do(Command::Drop(Spot::Pack(0), 5)));
+ assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Stay, "no use for one here");
+ assert_eq!(press(&mut inv, &view, KeyCode::Esc), Reply::Close);
+ }
+
+ #[test]
+ fn at_an_armourers_you_buy_off_the_rack_and_sell_what_you_can_spare() {
+ // The torch and rivet gun ready, a found weapon and medpatches packed.
+ let gear = gear();
+ let found = gear.pack.iter().position(|s| matches!(s.item, Item::Weapon(_))).unwrap();
+ let mut rng = seed::rng(4);
+ let rack: Vec<Stack> = (0..3)
+ .map(|_| Stack::one(Item::Weapon(arms::stocked(&mut rng, Culture::Cant, false))))
+ .collect();
+ let aboard = [Stack::one(Item::Medpatch), rack[0].clone()];
+ let terms = trade::armoury_terms(&crate::space::system::generate(1), 0);
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: Some((Beside::Rack, &rack)),
+ known: &known,
+ hurt: false,
+ shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }),
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::beside();
+ let Some(Row::Beside(i)) = inv.picked(&view) else { panic!("starts on the rack") };
+ assert_eq!(typed(&mut inv, &view, "b")[0], Reply::Do(Command::Buy(i)));
+ assert_eq!(typed(&mut inv, &view, "m")[0], Reply::Stay, "you can't just take it");
+ press(&mut inv, &view, KeyCode::Char('h'));
+ // Only what they'd buy from your lockers, under its own heading.
+ let rows = inv.rows(&view, false);
+ let heading = rows.iter().position(|r| *r == Row::Heading("IN YOUR SHIP")).unwrap();
+ assert_eq!(rows[heading + 1..], [Row::Aboard(1)]);
+ typed(&mut inv, &view, "G");
+ assert_eq!(typed(&mut inv, &view, "s")[0], Reply::Do(Command::SellAboard(1)));
+ let acts = |row| Inventory::acts(&view, row, 1);
+ let sells = |row| acts(row).iter().any(|a| a.key == 's');
+ assert!(sells(Row::You(Spot::Pack(found))));
+ let Item::Weapon(w) = &gear.pack[found].item else { unreachable!() };
+ let (spared, alone) =
+ if w.is_gun() { (Spot::Gun, Spot::Melee) } else { (Spot::Melee, Spot::Gun) };
+ assert!(sells(Row::You(spared)), "ready, with a spare to take its place");
+ assert!(!sells(Row::You(alone)), "not with nothing else to fight with");
+ assert!(!sells(Row::You(Spot::Pack(1 - found))), "they don't buy medpatches");
+ // What they'd pay here, beside what it weighs.
+ let item = &gear.pack[found].item;
+ let price = terms.offer(item).unwrap();
+ let line = inv.row_line(&view, Row::You(Spot::Pack(found)), 40).to_string();
+ let kg = items::kg(item.mass());
+ assert!(line.ends_with(&format!("{kg:>5} {price:>5} ")), "{line}");
+ assert!(line.starts_with(" ) "), "indented under its heading: {line}");
+ }
+
+ #[test]
+ fn rows_show_what_things_weigh_and_about_what_theyd_fetch() {
+ let mut gear = gear();
+ let mut rng = seed::rng(3);
+ let scrap = items::Salvage { title: "hull plate".into(), value: 20, mass: 12_000 };
+ gear.add(Stack { item: Item::Salvage(scrap), count: 3 });
+ gear.add(Stack::one(Item::Artifact(crate::names::artifact(&mut rng))));
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let inv = Inventory::new();
+ let line = |row| inv.row_line(&view, row, 50).to_string();
+ let at = |pick: fn(&Item) -> bool| {
+ Row::You(Spot::Pack(gear.pack.iter().position(|s| pick(&s.item)).unwrap()))
+ };
+ // A whole stack's worth, and its weight.
+ assert!(line(at(|i| matches!(i, Item::Salvage(_)))).ends_with(" 36.0 ~60 "));
+ assert!(line(at(|i| matches!(i, Item::Artifact(_)))).ends_with(" ? "), "not named yet");
+ assert!(line(at(|i| *i == Item::Medpatch)).ends_with(" "), "no one buys them");
+ let weapon = at(|i| matches!(i, Item::Weapon(_)));
+ let Row::You(Spot::Pack(w)) = weapon else { unreachable!() };
+ let about = trade::typical_offer(&gear.pack[w].item).unwrap();
+ assert!(line(weapon).ends_with(&format!("~{about} ")));
+ // The headings say which column is which.
+ let heading = inv.row_line(&view, Row::Heading("WEAPONS"), 50).to_string();
+ assert!(heading.starts_with(" WEAPONS") && heading.ends_with(" kg ¤ "), "{heading}");
+ // Too narrow for both, just the price.
+ assert!(inv.row_line(&view, weapon, 24).to_string().ends_with(&format!("~{about} ")));
+ assert!(!inv.row_line(&view, weapon, 24).to_string().contains(" kg"));
+ }
+
+ #[test]
+ fn o_sorts_both_lists_within_each_kind_and_o_turns_it_round() {
+ let mut gear = Gear::default();
+ let scrap = |title: &str, value, mass| {
+ Stack::one(Item::Salvage(items::Salvage { title: title.into(), value, mass }))
+ };
+ gear.add(scrap("brass fitting", 30, 1000));
+ gear.add(scrap("hull plate", 20, 12_000));
+ gear.add(scrap("coil", 50, 4000));
+ gear.add(Stack { item: Item::Medpatch, count: 2 });
+ let floor = [scrap("valve", 5, 500), scrap("anchor", 90, 30_000)];
+ let known = BTreeSet::new();
+ let mut view = View {
+ gear: &gear,
+ beside: Some((Beside::Floor("on the floor"), &floor)),
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ let salvage = |inv: &Inventory, view: &View, beside: bool| -> Vec<String> {
+ inv.rows(view, beside)
+ .into_iter()
+ .filter_map(|r| Inventory::stack(view, r))
+ .filter(|s| matches!(s.item, Item::Salvage(_)))
+ .map(|s| s.item.label())
+ .collect()
+ };
+ assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]);
+ // On the coil, then sorted heaviest first: the cursor stays on it.
+ inv.cursor[0] =
+ inv.picks(&view, false).iter().position(|r| *r == Row::You(Spot::Pack(2))).unwrap();
+ let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('o')) else { panic!() };
+ assert_eq!(sort, Sort { by: SortBy::Weight, reversed: false });
+ view.sort = sort;
+ assert_eq!(salvage(&inv, &view, false), ["hull plate", "coil", "brass fitting"]);
+ assert_eq!(salvage(&inv, &view, true), ["anchor", "valve"], "the floor too");
+ assert_eq!(inv.picked(&view), Some(Row::You(Spot::Pack(2))));
+ let rows = inv.rows(&view, false);
+ let medical = rows.iter().position(|r| *r == Row::Heading("MEDICAL")).unwrap();
+ let salvage_at = rows.iter().position(|r| *r == Row::Heading("SALVAGE")).unwrap();
+ assert!(medical < salvage_at, "still grouped by kind");
+
+ let Reply::Sort(sort) = press(&mut inv, &view, KeyCode::Char('O')) else { panic!() };
+ view.sort = sort;
+ assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]);
+ assert_eq!(sort.says(), "lightest first");
+
+ view.sort = Sort { by: SortBy::Density, reversed: false };
+ assert_eq!(salvage(&inv, &view, false), ["brass fitting", "coil", "hull plate"]);
+ view.sort = Sort { by: SortBy::Newest, reversed: false };
+ assert_eq!(salvage(&inv, &view, false), ["coil", "hull plate", "brass fitting"]);
+ // Round they go, back to names.
+ let mut by = SortBy::Name;
+ for _ in 0..SortBy::ALL.len() {
+ by = by.next();
+ }
+ assert_eq!(by, SortBy::Name);
+ }
+
+ #[test]
+ fn worn_armour_sorts_first_by_durability_and_the_rest_after() {
+ let mut rng = seed::rng(9);
+ let mut gear = Gear::default();
+ for left in [10, 2, 6] {
+ let mut a = crate::dungeon::armour::stocked(&mut rng, Culture::Cant, false);
+ (a.title, a.max_durability, a.durability) = (format!("piece {left}"), 10, left);
+ gear.add(Stack::one(Item::Armor(a)));
+ }
+ let known = BTreeSet::new();
+ let mut view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort { by: SortBy::Durability, reversed: false },
+ };
+ let inv = Inventory::new();
+ let armour = |view: &View| -> Vec<String> {
+ inv.rows(view, false)
+ .into_iter()
+ .filter_map(|r| match r {
+ Row::You(Spot::Pack(_)) => Inventory::stack(view, r),
+ _ => None,
+ })
+ .map(|s| s.item.label())
+ .collect()
+ };
+ assert_eq!(armour(&view), ["piece 2", "piece 6", "piece 10"]);
+ view.sort.reversed = true;
+ assert_eq!(armour(&view), ["piece 10", "piece 6", "piece 2"]);
+ }
+
+ #[test]
+ fn at_an_armourers_worn_armour_can_be_mended() {
+ let mut rng = seed::rng(6);
+ let mut piece = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false);
+ let mut gear = Gear::default();
+ piece.durability = 1;
+ gear.worn.insert(piece.slot, piece.clone());
+ let mut spare = piece.clone();
+ spare.slot = piece.slot;
+ gear.add(Stack::one(Item::Armor(spare)));
+ let whole = {
+ let mut p = piece.clone();
+ p.repair();
+ p
+ };
+ let aboard = [Stack::one(Item::Armor(whole))];
+ let terms = trade::armoury_terms(&crate::space::system::generate(1), 0);
+ let known = BTreeSet::new();
+ let rack = Vec::new();
+ let view = View {
+ gear: &gear,
+ beside: Some((Beside::Rack, &rack)),
+ known: &known,
+ hurt: false,
+ shop: Some(Counter { terms, aboard: &aboard, scrip: 40 }),
+ sort: Sort::default(),
+ };
+ let cost = terms.mending(&piece);
+ let acts = Inventory::acts(&view, Row::You(Spot::Worn(piece.slot)), 1);
+ let mend = acts.iter().find(|a| a.key == 'r').expect("an act to mend it");
+ assert_eq!(mend.what, format!("mend it for {cost} ¤"));
+ let all = acts.iter().find(|a| a.key == 'R').expect("and to mend them all");
+ assert_eq!(all.what, format!("mend all 2 for {} ¤", cost * 2));
+ let whole_acts = Inventory::acts(&view, Row::Aboard(0), 1);
+ assert!(!whole_acts.iter().any(|a| a.key == 'r'), "nothing to mend in a whole one");
+ // R mends everything from wherever the cursor is.
+ let mut inv = Inventory::beside();
+ assert_eq!(typed(&mut inv, &view, "R")[0], Reply::Do(Command::MendAll));
+ // The list shows how worn it is.
+ let line = inv.row_line(&view, Row::You(Spot::Worn(piece.slot)), 60).to_string();
+ assert!(line.contains(&format!(" 1/{}", piece.max_durability)), "{line}");
+ }
+
+ #[test]
+ fn armour_says_how_it_measures_up_to_what_you_wear() {
+ let mut rng = seed::rng(6);
+ let mut worn = crate::dungeon::armour::stocked(&mut rng, Culture::Drift, false);
+ worn.title = "vac-helmet".into();
+ worn.guards = [(Dmg::Pierce, 1), (Dmg::Blunt, 1)].into_iter().collect();
+ worn.mass = 1800;
+ let mut other = worn.clone();
+ other.guards = [(Dmg::Cut, 1), (Dmg::Blunt, 1)].into_iter().collect();
+ other.mass = 1300;
+ assert_eq!(
+ versus_armour(&other, Some(&worn)).to_string(),
+ "Against your vac-helmet: +1 cut, -1 pierce, -0.5 kg."
+ );
+ assert!(versus_armour(&other, None).to_string().starts_with("You wear nothing on your"));
+ }
+
+ #[test]
+ fn a_weapon_says_how_it_measures_up_to_the_one_you_have_ready() {
+ let torch = arms::cutting_torch();
+ let mut better = torch.clone();
+ better.damage = (4, 7);
+ better.accuracy = 0.75;
+ assert_eq!(
+ versus(&better, &torch).to_string(),
+ "Against your cutting torch: +2 damage, -5% to hit."
+ );
+ assert_eq!(
+ versus(&torch, &torch).to_string(),
+ "Against your cutting torch: much the same."
+ );
+ let gun = arms::rivet_gun();
+ let mut longer = gun.clone();
+ longer.gun.as_mut().unwrap().range += 4;
+ longer.dmg = Dmg::Shock;
+ assert_eq!(
+ versus(&longer, &gun).to_string(),
+ "Against your rivet gun: +4 range, shock not pierce."
+ );
+ }
+
+ #[test]
+ fn finding_narrows_the_list() {
+ let gear = gear();
+ let known = BTreeSet::new();
+ let view = View {
+ gear: &gear,
+ beside: None,
+ known: &known,
+ hurt: true,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ press(&mut inv, &view, KeyCode::Char('/'));
+ typed(&mut inv, &view, "medp");
+ press(&mut inv, &view, KeyCode::Enter);
+ assert_eq!(inv.picks(&view, false), [Row::You(Spot::Pack(0))]);
+ assert_eq!(typed(&mut inv, &view, "a")[0], Reply::Do(Command::UseMedpatch));
+ press(&mut inv, &view, KeyCode::Esc);
+ assert_eq!(inv.picks(&view, false).len(), 8, "esc clears it");
+ }
+
+ #[test]
+ fn the_menu_lists_what_you_can_do() {
+ let gear = gear();
+ let known = BTreeSet::new();
+ let lockers = vec![Stack { item: Item::Medpatch, count: 2 }];
+ let view = View {
+ gear: &gear,
+ beside: Some((Beside::Lockers, &lockers)),
+ known: &known,
+ hurt: false,
+ shop: None,
+ sort: Sort::default(),
+ };
+ let mut inv = Inventory::new();
+ typed(&mut inv, &view, "G");
+ press(&mut inv, &view, KeyCode::Enter);
+ assert_eq!(inv.menu, Some(0));
+ typed(&mut inv, &view, "jj");
+ // drop one, drop them all, stow one, stow them all.
+ assert_eq!(
+ press(&mut inv, &view, KeyCode::Enter),
+ Reply::Do(Command::Stow(Spot::Pack(0), 1))
+ );
+ typed(&mut inv, &view, "l");
+ assert_eq!(typed(&mut inv, &view, "M")[0], Reply::Do(Command::Fetch(0, 2)));
+ }
+
+ #[test]
+ fn carrying_out_moves_things_about() {
+ let mut gear = gear();
+ let mut lockers = Vec::new();
+ let mut floor = Vec::new();
+ let lockers_beside = Some(Beside::Lockers);
+ let said = carry_out(
+ Command::Stow(Spot::Pack(0), 2),
+ &mut gear,
+ Some(&mut lockers),
+ &mut floor,
+ lockers_beside,
+ );
+ assert_eq!(said, "You stow 2 medpatches in the lockers.");
+ assert_eq!((gear.medpatches(), lockers[0].count), (3, 2));
+ carry_out(Command::Fetch(0, 9), &mut gear, Some(&mut lockers), &mut floor, lockers_beside);
+ assert_eq!((gear.medpatches(), lockers.len()), (5, 0));
+ // Your own torch can go, and the blade in your pack takes its place;
+ // but not the blade too, with nothing left to hit things with.
+ let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None);
+ assert_eq!(said, "You put down your cutting torch.");
+ assert!(!gear.melee.kit);
+ let said = carry_out(Command::Drop(Spot::Melee, 1), &mut gear, None, &mut floor, None);
+ assert!(said.contains("nothing to hit things with"), "{said}");
+ assert_eq!(floor.len(), 1);
+ let said = carry_out(
+ Command::Fetch(0, 1),
+ &mut gear,
+ None,
+ &mut floor,
+ Some(Beside::Floor("on the deck")),
+ );
+ assert!(said.starts_with("You take"), "{said}");
+ assert!(floor.is_empty());
+ }
+
+ #[test]
+ fn piles_gather_and_scatter() {
+ let mut piles = vec![
+ (Pos::new(1, 1), Stack::one(Item::Medpatch)),
+ (Pos::new(2, 2), Stack::one(Item::Medpatch)),
+ (Pos::new(1, 1), Stack { item: Item::Medpatch, count: 3 }),
+ ];
+ let here = gather(&mut piles, Pos::new(1, 1));
+ assert_eq!((here.len(), piles.len()), (2, 1));
+ scatter(&mut piles, Pos::new(1, 1), here);
+ assert_eq!(lying(&piles, Pos::new(1, 1)).len(), 2);
+ assert!(underfoot(&lying(&piles, Pos::new(2, 2))).unwrap().contains("a medpatch"));
+ }
+}
diff --git a/src/scenes/keys.rs b/src/scenes/keys.rs
new file mode 100644
index 0000000..8c07fed
--- /dev/null
+++ b/src/scenes/keys.rs
@@ -0,0 +1,97 @@
+//! Keys every screen shares: which way the movement keys go, and how key
+//! hints are drawn. A hint is the key in the accent colour, bold, then what
+//! it does, dimmed; hints sit two spaces apart, and side panels pack as
+//! many to a line as fit.
+
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use ratatui::style::{Color, Stylize};
+use ratatui::text::{Line, Span};
+
+pub const ACCENT: Color = Color::Rgb(240, 200, 110);
+pub const DIM: Color = Color::Rgb(120, 124, 142);
+
+/// Vi keys, arrows, and the number pad with or without num lock.
+pub fn direction(code: KeyCode) -> Option<(i32, i32)> {
+ Some(match code {
+ KeyCode::Left | KeyCode::Char('h' | '4') => (-1, 0),
+ KeyCode::Right | KeyCode::Char('l' | '6') => (1, 0),
+ KeyCode::Up | KeyCode::Char('k' | '8') => (0, -1),
+ KeyCode::Down | KeyCode::Char('j' | '2') => (0, 1),
+ KeyCode::Home | KeyCode::Char('y' | '7') => (-1, -1),
+ KeyCode::PageUp | KeyCode::Char('u' | '9') => (1, -1),
+ KeyCode::End | KeyCode::Char('b' | '1') => (-1, 1),
+ KeyCode::PageDown | KeyCode::Char('n' | '3') => (1, 1),
+ _ => return None,
+ })
+}
+
+/// A step, or with shift a run: which way, and whether to run. Shifted vi
+/// keys come as capitals.
+pub fn movement(key: KeyEvent) -> Option<(i32, i32, bool)> {
+ let (code, run) = match key.code {
+ KeyCode::Char(c) if c.is_ascii_uppercase() => (KeyCode::Char(c.to_ascii_lowercase()), true),
+ code => (code, key.modifiers.contains(KeyModifiers::SHIFT)),
+ };
+ direction(code).map(|(dx, dy)| (dx, dy, run))
+}
+
+/// One hint: "esc back".
+fn hint(key: &str, what: &str) -> [Span<'static>; 2] {
+ [Span::raw(key.to_string()).fg(ACCENT).bold(), Span::raw(format!(" {what}")).fg(DIM)]
+}
+
+/// Hints in a row.
+pub fn line(hints: &[(&str, &str)]) -> Line<'static> {
+ let mut spans = Vec::new();
+ for (i, &(key, what)) in hints.iter().enumerate() {
+ if i > 0 {
+ spans.push(Span::raw(" "));
+ }
+ spans.extend(hint(key, what));
+ }
+ Line::from(spans)
+}
+
+/// Hints packed into lines at most `width` wide, as many to a line as fit.
+/// A hint is never split; one too wide for any line gets a line to itself.
+pub fn packed(hints: &[(&str, &str)], width: u16) -> Vec<Line<'static>> {
+ let width = usize::from(width);
+ let mut lines: Vec<Line<'static>> = Vec::new();
+ for &(key, what) in hints {
+ let [k, w] = hint(key, what);
+ let needs = k.width() + w.width();
+ match lines.last_mut() {
+ Some(last) if last.width() + 2 + needs <= width => {
+ last.spans.extend([Span::raw(" "), k, w]);
+ }
+ _ => lines.push(Line::from(vec![k, w])),
+ }
+ }
+ lines
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn text(line: &Line) -> String {
+ line.spans.iter().map(|s| s.content.as_ref()).collect()
+ }
+
+ #[test]
+ fn hints_pack_without_splitting() {
+ let hints = [("f", "fire"), ("r", "reload"), ("esc", "menu: save and quit")];
+ let lines: Vec<String> = packed(&hints, 16).iter().map(text).collect();
+ assert_eq!(lines, ["f fire r reload", "esc menu: save and quit"]);
+ assert_eq!(text(&line(&hints[..2])), "f fire r reload");
+ }
+
+ #[test]
+ fn shift_or_a_capital_runs() {
+ let key = |code, modifiers| movement(KeyEvent::new(code, modifiers));
+ assert_eq!(key(KeyCode::Char('h'), KeyModifiers::NONE), Some((-1, 0, false)));
+ assert_eq!(key(KeyCode::Char('L'), KeyModifiers::SHIFT), Some((1, 0, true)));
+ assert_eq!(key(KeyCode::Down, KeyModifiers::SHIFT), Some((0, 1, true)));
+ assert_eq!(key(KeyCode::Char('G'), KeyModifiers::SHIFT), None);
+ }
+}
diff --git a/src/scenes/mod.rs b/src/scenes/mod.rs
index 60db647..1c20edf 100644
--- a/src/scenes/mod.rs
+++ b/src/scenes/mod.rs
@@ -1,9 +1,25 @@
-//! Scenes: the title screen, the name forge, and derelicts to board;
-//! flight and ports later.
+//! Scenes: the title screen, characters and their saves, the name forge,
+//! derelicts to board, flight (around a place, across the system map, and
+//! between systems on the star map), and what's on the ground: cities to
+//! walk, and ports.
+pub mod aboard;
+pub mod character;
pub mod derelict;
+pub mod flight;
pub mod forge;
+pub mod galaxy_map;
+pub mod inventory;
+pub mod keys;
+pub mod pause;
+pub mod port;
+pub mod saves;
+pub mod sky;
+pub mod system_map;
pub mod title;
+pub mod town;
+#[cfg(test)]
+mod zz_dump;
use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
@@ -15,8 +31,14 @@ pub enum Transition {
None,
/// Open a scene on top of this one. Popping it comes back here.
Push(Box<dyn Scene>),
+ /// Open several scenes at once, each on top of the one before.
+ Open(Vec<Box<dyn Scene>>),
/// Close this scene and return to the one beneath it.
Pop,
+ /// Close this scene and open these in its place.
+ Replace(Vec<Box<dyn Scene>>),
+ /// Close every scene but the title screen, at the bottom of the stack.
+ Title,
Quit,
}
@@ -33,10 +55,15 @@ pub trait Scene {
}
fn render(&self, frame: &mut Frame, ctx: &Ctx);
+
+ /// This scene has come to the top of the stack: just opened, or
+ /// uncovered when the ones above it closed. What it shows may need
+ /// bringing up to date with what happened up there.
+ fn on_top(&mut self, _ctx: &Ctx) {}
}
#[cfg(test)]
-mod tests {
+pub(crate) mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
@@ -53,13 +80,130 @@ mod tests {
#[test]
fn scenes_render_at_any_size() {
let mut ctx = test_ctx();
+ let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
+ let mut pausing = derelict::Derelict::new(2);
+ pausing.key(esc, &mut ctx);
let mut abandoning = derelict::Derelict::new(2);
- abandoning.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut ctx);
- let scenes: [Box<dyn Scene>; 4] = [
- Box::new(title::Title::new(&ctx)),
+ abandoning.key(esc, &mut ctx);
+ abandoning.key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), &mut ctx);
+ let mut aiming = derelict::Derelict::new(2);
+ aiming.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), &mut ctx);
+ aiming.key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), &mut ctx);
+ let mut packing = derelict::Derelict::new(2);
+ packing.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), &mut ctx);
+ // Something of every kind, on you and in the lockers.
+ {
+ use crate::dungeon::armour;
+ use crate::dungeon::arms::Source;
+ use crate::items::{Food, Item, Salvage, Slot, Stack};
+ let mut rng = crate::seed::rng(8);
+ let mut armor = |slot| {
+ let mut a = armour::find(&mut rng, Source::Armoury, false);
+ a.slot = slot;
+ a.durability = 1;
+ a
+ };
+ let food = Food {
+ title: "ration brick".into(),
+ description: "dense and grey".into(),
+ nourishment: 10,
+ mass: 400,
+ };
+ let scrap = Salvage { title: "coil of superconductor".into(), value: 30, mass: 6000 };
+ let g = &mut ctx.hold.gear;
+ g.worn.insert(Slot::Body, armor(Slot::Body));
+ g.add(Stack::one(Item::Armor(armor(Slot::Head))));
+ g.add(Stack { item: Item::Food(food), count: 3 });
+ g.add(Stack { item: Item::Medpatch, count: 2 });
+ ctx.hold.lockers.push(Stack { item: Item::Salvage(scrap.clone()), count: 4 });
+ ctx.hold.deck.push((crate::dungeon::Pos::new(0, 0), Stack::one(Item::Salvage(scrap))));
+ }
+ let mut rummaging = derelict::Derelict::board(2, &mut ctx);
+ for code in [KeyCode::Char('i'), KeyCode::Char('j'), KeyCode::Enter] {
+ rummaging.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx);
+ }
+ let home = ctx.system.home_place();
+ let mut stowing = aboard::Aboard::landed(&ctx, home);
+ for code in [KeyCode::Char('i'), KeyCode::Char('l'), KeyCode::Char('/')] {
+ stowing.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx);
+ }
+ let mut strolling = town::TownScene::new(&ctx, home);
+ strolling.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), &mut ctx);
+ let mut leaving = town::TownScene::new(&ctx, home);
+ leaving.key(esc, &mut ctx);
+ let mut resting = aboard::Aboard::landed(&ctx, home);
+ resting.key(esc, &mut ctx);
+ let mut flying = flight::FlightScene::new(&ctx);
+ flying.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx);
+ let mut holding = flight::FlightScene::new(&ctx);
+ holding.key(esc, &mut ctx);
+ let mut docked = port::Port::new(&ctx, 0);
+ docked.key(esc, &mut ctx);
+ let belt = ctx.system.places.len() - 1;
+ let mut trading = town::TownScene::new(&ctx, home);
+ for _ in 0..12 {
+ trading.key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut ctx);
+ }
+ let wreck = ctx.system.wrecks_on(ctx.day).pop();
+ let mut renaming = character::Sheet::new();
+ renaming.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), &mut ctx);
+ let mut creating = character::Create::new(&ctx);
+ creating.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut ctx);
+ let dir = crate::save::scratch("render");
+ ctx.saves = Some(dir.clone());
+ ctx.slot = Some(crate::save::new_slot(&dir, &ctx.character, ctx.seed));
+ ctx.record.slain.insert("rust-mite".into(), 2);
+ ctx.bestiary.insert(("rust-mite", crate::dungeon::arms::Dmg::Blunt));
+ ctx.autosave();
+ std::fs::write(dir.join("broken.json"), "{").unwrap();
+ let playing = title::Title::new(&ctx);
+ let mut plotting = galaxy_map::GalaxyMap::new(&ctx);
+ plotting.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx);
+ let mut far = galaxy_map::GalaxyMap::new(&ctx);
+ for _ in 0..6 {
+ far.key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut ctx);
+ }
+ let mut stranded = test_ctx();
+ let dead = (0..stranded.galaxy.sites.len())
+ .find(|&i| !stranded.galaxy.sites[i].kind.sells_fuel())
+ .unwrap();
+ stranded.enter_system(dead);
+ stranded.hold.fuel = 0;
+ stranded.hold.debt = 120;
+ let scenes: [Box<dyn Scene>; 33] = [
+ Box::new(title::Title::new(&test_ctx())),
+ Box::new(playing),
+ Box::new(character::Create::new(&ctx)),
+ Box::new(creating),
+ Box::new(character::Sheet::new()),
+ Box::new(renaming),
+ Box::new(saves::Saves::new(&ctx)),
Box::new(forge::Forge::new(&ctx)),
Box::new(derelict::Derelict::new(1)),
+ Box::new(pausing),
Box::new(abandoning),
+ Box::new(holding),
+ Box::new(docked),
+ Box::new(leaving),
+ Box::new(resting),
+ Box::new(aiming),
+ Box::new(packing),
+ Box::new(rummaging),
+ Box::new(stowing),
+ Box::new(strolling),
+ Box::new(flying),
+ Box::new(system_map::SystemMap::new(&ctx)),
+ Box::new(galaxy_map::GalaxyMap::new(&ctx)),
+ Box::new(plotting),
+ Box::new(far),
+ Box::new(port::Port::new(&ctx, ctx.system.home_place())),
+ Box::new(port::Port::new(&ctx, belt)),
+ Box::new(port::Port::new(&ctx, 0)),
+ Box::new(town::TownScene::new(&ctx, home)),
+ Box::new(trading),
+ Box::new(aboard::Aboard::landed(&ctx, home)),
+ Box::new(aboard::Aboard::resumed(&ctx, home)),
+ Box::new(aboard::Aboard::adrift("in orbit around nowhere", wreck)),
];
for (width, height) in [(160, 50), (80, 24), (40, 12), (5, 3), (1, 1)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
@@ -69,6 +213,9 @@ mod tests {
terminal.draw(|frame| scene.render(frame, &ctx)).unwrap();
}
}
+ let map = galaxy_map::GalaxyMap::new(&stranded);
+ terminal.draw(|frame| map.render(frame, &stranded)).unwrap();
}
+ let _ = std::fs::remove_dir_all(dir);
}
}
diff --git a/src/scenes/pause.rs b/src/scenes/pause.rs
new file mode 100644
index 0000000..41cff43
--- /dev/null
+++ b/src/scenes/pause.rs
@@ -0,0 +1,160 @@
+//! The menu `esc` opens over a game screen: keep playing, do what `esc`
+//! used to do there (abandon ship, back to your ship), or save and quit to
+//! the title. It opens on keep playing, and `esc` again closes it: quitting
+//! is always something you choose.
+//!
+//! Like the inventory, it's a panel a scene holds and draws over itself,
+//! not a scene of its own: the scene beneath stays in view, and stops while
+//! the menu's open.
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+use ratatui::style::{Modifier, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap};
+
+use super::keys::{self, ACCENT, DIM};
+
+/// What you chose.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Choice {
+ /// Save, and go back to the title screen.
+ Quit,
+ /// Whatever `esc` does here besides: abandon ship, leave the building.
+ Here,
+ /// Close the menu and carry on.
+ Resume,
+}
+
+pub struct Pause {
+ choice: usize,
+ /// What else you can do here, if there's anything: "Abandon ship".
+ here: Option<&'static str>,
+ /// A word about where you are, or which way to go.
+ note: Option<String>,
+}
+
+impl Pause {
+ pub fn new(here: Option<&'static str>, note: Option<String>) -> Pause {
+ Pause { choice: 0, here, note }
+ }
+
+ fn choices(&self) -> Vec<(Choice, &'static str)> {
+ let mut choices = vec![(Choice::Resume, "Keep playing")];
+ if let Some(here) = self.here {
+ choices.push((Choice::Here, here));
+ }
+ choices.push((Choice::Quit, "Save and quit to the title"));
+ choices
+ }
+
+ /// Takes a key. Says what you chose, if you chose anything. A key held
+ /// down doesn't count again: holding `esc` mustn't flicker the menu.
+ pub fn key(&mut self, key: KeyEvent) -> Option<Choice> {
+ if key.kind == KeyEventKind::Repeat {
+ return None;
+ }
+ let choices = self.choices();
+ let last = choices.len() - 1;
+ match key.code {
+ KeyCode::Esc | KeyCode::Char('q') => Some(Choice::Resume),
+ KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
+ self.choice = (self.choice + 1).min(last);
+ None
+ }
+ KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
+ self.choice = self.choice.saturating_sub(1);
+ None
+ }
+ KeyCode::Char('g') | KeyCode::Home => {
+ self.choice = 0;
+ None
+ }
+ KeyCode::Char('G') | KeyCode::End => {
+ self.choice = last;
+ None
+ }
+ KeyCode::Enter | KeyCode::Char(' ' | 'l') => Some(choices[self.choice.min(last)].0),
+ KeyCode::Char(c @ '1'..='9') => {
+ choices.get(c as usize - '1' as usize).map(|&(choice, _)| choice)
+ }
+ _ => None,
+ }
+ }
+
+ /// Draws the menu in the middle of `area`.
+ pub fn render(&self, frame: &mut Frame, area: Rect) {
+ let mut lines = Vec::new();
+ if let Some(note) = &self.note {
+ lines.push(Line::from(note.clone()).fg(DIM));
+ lines.push(Line::default());
+ }
+ for (i, (_, what)) in self.choices().into_iter().enumerate() {
+ let chosen = i == self.choice;
+ let marker = if chosen { "› " } else { " " };
+ let line = Line::from(vec![
+ Span::raw(format!("{} ", i + 1)).fg(ACCENT),
+ Span::raw(marker).fg(ACCENT),
+ Span::raw(what),
+ ]);
+ lines.push(if chosen {
+ line.patch_style(Style::new().add_modifier(Modifier::REVERSED))
+ } else {
+ line
+ });
+ }
+ lines.push(Line::default());
+ lines.push(keys::line(&[("↑↓ enter", "choose"), ("esc", "keep playing")]));
+
+ let [popup] = Layout::horizontal([Constraint::Max(48)]).flex(Flex::Center).areas(area);
+ let width = usize::from(popup.width.saturating_sub(4)).max(1);
+ let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum();
+ let height = u16::try_from(rows + 2).unwrap_or(u16::MAX);
+ let [popup] =
+ Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup);
+ let block = Block::bordered()
+ .border_style(Style::new().fg(ACCENT))
+ .title(Line::from(" PAUSED ").fg(ACCENT).bold())
+ .padding(Padding::horizontal(1));
+ frame.render_widget(Clear, popup);
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), popup);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+
+ fn press(pause: &mut Pause, code: KeyCode) -> Option<Choice> {
+ pause.key(KeyEvent::new(code, KeyModifiers::NONE))
+ }
+
+ #[test]
+ fn esc_again_closes_the_menu_and_holding_it_does_nothing() {
+ let mut pause = Pause::new(Some("Abandon ship"), None);
+ let held = KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Repeat);
+ assert_eq!(pause.key(held), None);
+ assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume));
+ assert_eq!(press(&mut pause, KeyCode::Char('j')), None);
+ assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume), "wherever the cursor is");
+ }
+
+ #[test]
+ fn you_can_pick_what_else_esc_does_or_carry_on() {
+ let mut pause = Pause::new(Some("Abandon ship"), None);
+ assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Resume), "it opens on that");
+ assert_eq!(press(&mut pause, KeyCode::Char('j')), None);
+ assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Here));
+ assert_eq!(press(&mut pause, KeyCode::Char('G')), None);
+ assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Quit));
+ assert_eq!(press(&mut pause, KeyCode::Char('1')), Some(Choice::Resume));
+ assert_eq!(press(&mut pause, KeyCode::Char('3')), Some(Choice::Quit));
+ assert_eq!(press(&mut pause, KeyCode::Char('q')), Some(Choice::Resume));
+ assert_eq!(press(&mut pause, KeyCode::Char('x')), None, "anything else does nothing");
+ let mut pause = Pause::new(None, None);
+ assert_eq!(press(&mut pause, KeyCode::Char('2')), Some(Choice::Quit));
+ }
+}
diff --git a/src/scenes/port.rs b/src/scenes/port.rs
new file mode 100644
index 0000000..5595822
--- /dev/null
+++ b/src/scenes/port.rs
@@ -0,0 +1,330 @@
+//! Docked at a station, or landed somewhere no one lives: what the place
+//! is like, and at a station, a market for the ore in your hold and the
+//! salvage you've brought back from wrecks. Worlds
+//! where people live have whole cities instead (`scenes::town`), which you
+//! walk out to through your ship; here there's no city, so landing brings
+//! you straight here, and leaving takes off again.
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Flex, Layout};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Padding, Paragraph, Wrap};
+
+use super::character::Sheet;
+use super::keys::{self, ACCENT, DIM};
+use super::pause::{Choice, Pause};
+use super::sky;
+use super::{Scene, Transition};
+use crate::app::{Ctx, Hold};
+use crate::items;
+use crate::names;
+use crate::space::locale::Ore;
+use crate::space::system::{PlaceKind, System};
+use crate::trade;
+
+/// The colour fuel is shown in, everywhere.
+pub const FUEL: Color = Color::Rgb(120, 210, 255);
+
+pub struct Port {
+ title: String,
+ /// "a desert world, home of the Qaari".
+ subtitle: String,
+ description: Vec<String>,
+ colour: Color,
+ /// What each ore sells for here, if anyone's buying.
+ prices: Option<Vec<(&'static Ore, u32)>>,
+ /// What a jump's worth of fuel costs, at a station.
+ fuel_price: Option<u32>,
+ /// What happened at the market.
+ report: Option<String>,
+ place: usize,
+ /// Whether this is a dock, which you undock from, or ground you take
+ /// off from.
+ docked: bool,
+ /// The menu `esc` opens.
+ pause: Option<Pause>,
+}
+
+impl Port {
+ pub fn new(ctx: &Ctx, place: usize) -> Port {
+ let s = &ctx.system;
+ let (title, subtitle, description, colour, people) = match s.places[place].kind {
+ PlaceKind::Planet(p) => {
+ let planet = &s.planets[p];
+ let home = if s.is_homeworld(p) { ", homeworld of " } else { ", settled by " };
+ let people = planet
+ .people
+ .map_or(", uninhabited".to_string(), |c| format!("{home}{}", c.name()));
+ (
+ planet.name.clone(),
+ format!("{}{people}", names::a(planet.kind.name)),
+ planet.description.clone(),
+ planet.kind.rgb,
+ planet.people,
+ )
+ }
+ PlaceKind::Moon(p, m) => {
+ let moon = &s.planets[p].moons[m];
+ let people = moon
+ .people
+ .map_or(", uninhabited".to_string(), |c| format!(", settled by {}", c.name()));
+ let kind = moon.kind.name.trim_end_matches(" world");
+ let what = format!("{} moon of {}{people}", names::a(kind), s.planets[p].name);
+ (moon.name.clone(), what, moon.description.clone(), moon.kind.rgb, moon.people)
+ }
+ PlaceKind::Station(i) => {
+ let station = &s.stations[i];
+ let (_, colour) = sky::station_art(station.culture);
+ let what = format!("a {} station", station.culture.adjective());
+ (
+ station.name.clone(),
+ what,
+ station.description.clone(),
+ colour,
+ Some(station.culture),
+ )
+ }
+ PlaceKind::Belt => (
+ s.belt.name.clone(),
+ "an asteroid belt".to_string(),
+ Vec::new(),
+ (150, 138, 124),
+ None,
+ ),
+ };
+ Port {
+ title,
+ subtitle,
+ description,
+ colour: sky::rgb(colour),
+ prices: people.map(|_| trade::ore_prices(s, place)),
+ fuel_price: s.sells_fuel_at(place).then(|| trade::fuel_price(s, place)),
+ report: None,
+ place,
+ docked: matches!(s.places[place].kind, PlaceKind::Station(_)),
+ pause: None,
+ }
+ }
+
+ fn refuel(&mut self, hold: &mut Hold) {
+ let Some(price) = self.fuel_price else {
+ self.report = Some("No one here sells fuel.".to_string());
+ return;
+ };
+ self.report = Some(match trade::buy_fuel(hold, price) {
+ (0, _) if hold.fuel >= hold.fuel_capacity => "Your tank's already full.".to_string(),
+ (0, _) => "You can't afford any fuel.".to_string(),
+ (jumps, cost) => format!(
+ "Bought {jumps} jump{} of fuel for {cost} ¤. Tank: {}/{}.",
+ if jumps == 1 { "" } else { "s" },
+ hold.fuel,
+ hold.fuel_capacity
+ ),
+ });
+ }
+
+ /// Sells all your ore, and all your salvage, on you and aboard.
+ fn sell(&mut self, hold: &mut Hold, system: &System) {
+ let Some(prices) = &self.prices else {
+ self.report = Some("No one lives here to buy anything.".to_string());
+ return;
+ };
+ let debt = hold.debt;
+ let ore = trade::sell_ore(hold, prices);
+ let salvage = trade::sell_all_salvage(hold, system, self.place);
+ let mut sold = Vec::new();
+ if let Some((tons, earned)) = ore {
+ sold.push(format!("{tons} t of ore for {earned} ¤"));
+ }
+ if let Some((pieces, earned)) = salvage {
+ let s = if pieces == 1 { "" } else { "s" };
+ sold.push(format!("{pieces} piece{s} of salvage for {earned} ¤"));
+ }
+ self.report = Some(if sold.is_empty() {
+ "You've no ore or salvage to sell.".to_string()
+ } else {
+ format!("Sold {}.{}", sold.join(" and "), trade::debt_note(debt - hold.debt, hold))
+ });
+ }
+}
+
+impl Scene for Port {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ 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 => Transition::Pop,
+ Choice::Resume => Transition::None,
+ };
+ }
+ match key.code {
+ KeyCode::Esc => {
+ let (here, note) = if self.docked {
+ ("Undock", format!("Docked at {}.", self.title))
+ } else {
+ ("Take off", format!("Landed on {}.", self.title))
+ };
+ self.pause = Some(Pause::new(Some(here), Some(note)));
+ Transition::None
+ }
+ KeyCode::Char('s' | 'S') => {
+ self.sell(&mut ctx.hold, &ctx.system);
+ Transition::None
+ }
+ KeyCode::Char('f' | 'F') => {
+ self.refuel(&mut ctx.hold);
+ Transition::None
+ }
+ KeyCode::Char('c' | 'C') => Transition::Push(Box::new(Sheet::new())),
+ // Only `l`, as it landed you: enter or space mashed through a
+ // message shouldn't take off.
+ KeyCode::Char('l' | 'L') => Transition::Pop,
+ _ => Transition::None,
+ }
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let area = frame.area();
+ let [area] = Layout::horizontal([Constraint::Max(68)]).flex(Flex::Center).areas(area);
+
+ let mut lines = vec![Line::from(self.subtitle.clone()).fg(DIM), Line::default()];
+ for sentence in &self.description {
+ lines.push(Line::from(sentence.clone()));
+ lines.push(Line::default());
+ }
+
+ let hold = &ctx.hold;
+ match &self.prices {
+ Some(prices) => {
+ lines.push(Line::from("THE MARKET").fg(ACCENT).bold());
+ for (ore, price) in prices {
+ let have = hold.ore.get(ore.name).copied().unwrap_or(0);
+ let have = if have > 0 { format!("you have {have} t") } else { String::new() };
+ lines.push(Line::from(vec![
+ Span::raw(" * ").fg(sky::rgb(ore.rgb)),
+ Span::raw(format!("{:<16}{:>4} ¤ a ton ", ore.name, price)),
+ Span::raw(have).fg(DIM),
+ ]));
+ }
+ }
+ None => lines.push(Line::from("No one lives here to trade with.").fg(DIM)),
+ }
+ if let Some(price) = self.fuel_price {
+ lines.push(Line::from(vec![
+ Span::raw(" ◆ ").fg(FUEL),
+ Span::raw(format!("{:<16}{:>4} ¤ a jump ", "fuel", price)),
+ Span::raw(format!("tank {}/{}", hold.fuel, hold.fuel_capacity)).fg(DIM),
+ ]));
+ }
+ lines.push(Line::default());
+ let salvage = if self.prices.is_some() {
+ trade::salvage_kept(hold, &ctx.system, self.place)
+ } else {
+ Vec::new()
+ };
+ if !salvage.is_empty() {
+ let worth: u32 = salvage.iter().map(|(_, w)| w).sum();
+ lines.push(Line::from(vec![
+ Span::raw(" ( ").fg(Color::Rgb(150, 200, 222)),
+ Span::raw(format!("{:<16}{:>4} ¤ the lot", "your salvage", worth)),
+ ]));
+ }
+ lines.push(Line::from(format!(
+ "Your hold: {} t of ore, {} artifacts, {} ¤. You carry {} of {} kg.",
+ hold.ore_tons(),
+ hold.artifacts(),
+ hold.scrip,
+ items::kg(hold.gear.carried()),
+ items::kg(hold.gear.capacity)
+ )));
+ if hold.debt > 0 {
+ lines.push(Line::from(format!("You owe {} ¤ for a tow.", hold.debt)).fg(DIM));
+ }
+ if let Some(report) = &self.report {
+ lines.push(Line::from(report.clone()).fg(ACCENT));
+ }
+ lines.push(Line::default());
+ let mut hints = vec![];
+ if self.prices.is_some() {
+ hints.push(("s", "sell ore and salvage"));
+ }
+ if self.fuel_price.is_some() {
+ hints.push(("f", "fill the tank"));
+ }
+ hints.push(("l", if self.docked { "undock" } else { "take off" }));
+ hints.extend([("c", "character"), ("esc", "menu")]);
+ lines.extend(keys::packed(&hints, area.width.saturating_sub(4)));
+
+ let block = Block::bordered()
+ .title(format!(" {} ", self.title))
+ .title_style(Style::new().fg(self.colour).bold())
+ .border_style(Style::new().fg(self.colour))
+ .padding(Padding::horizontal(1));
+ // As tall as the text, wrapped, and no taller.
+ let width = usize::from(area.width.saturating_sub(4)).max(1);
+ let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum();
+ let height = u16::try_from(rows + 3).unwrap_or(u16::MAX);
+ let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area);
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area);
+ if let Some(pause) = &self.pause {
+ pause.render(frame, frame.area());
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::scenes::tests::test_ctx;
+
+ #[test]
+ fn only_l_takes_off() {
+ use ratatui::crossterm::event::KeyModifiers;
+ let mut ctx = test_ctx();
+ let mut port = Port::new(&ctx, 0);
+ let mut press = |code| port.key(KeyEvent::new(code, KeyModifiers::NONE), &mut ctx);
+ assert!(matches!(press(KeyCode::Enter), Transition::None));
+ assert!(matches!(press(KeyCode::Char(' ')), Transition::None));
+ assert!(matches!(press(KeyCode::Char('c')), Transition::Push(_)));
+ assert!(matches!(press(KeyCode::Char('l')), Transition::Pop));
+ }
+
+ #[test]
+ fn stations_buy_ore() {
+ let mut ctx = test_ctx();
+ let station =
+ ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap();
+ let mut port = Port::new(&ctx, station);
+ let prices = port.prices.clone().expect("stations trade");
+ ctx.hold.ore.insert("iron ore", 3);
+ ctx.hold.ore.insert("iridium", 2);
+ let salvage = crate::items::Salvage { title: "hull plate".into(), value: 20, mass: 9000 };
+ ctx.hold
+ .lockers
+ .push(crate::items::Stack::one(crate::items::Item::Salvage(salvage.clone())));
+ port.sell(&mut ctx.hold, &ctx.system);
+ let scrap = trade::salvage_offer(&ctx.system, station, &salvage);
+ assert!(ctx.hold.lockers.is_empty(), "the salvage goes too");
+ let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).unwrap().1;
+ assert_eq!(ctx.hold.scrip, 3 * price("iron ore") + 2 * price("iridium") + scrap);
+ assert_eq!(ctx.hold.ore_tons(), 0);
+ }
+
+ #[test]
+ fn stations_sell_fuel() {
+ let mut ctx = test_ctx();
+ let station =
+ ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap();
+ let mut port = Port::new(&ctx, station);
+ let price = port.fuel_price.expect("stations sell fuel");
+ ctx.hold.fuel = 1;
+ ctx.hold.scrip = 10 * price;
+ port.key(KeyEvent::from(KeyCode::Char('f')), &mut ctx);
+ assert_eq!(ctx.hold.fuel, ctx.hold.fuel_capacity);
+ assert_eq!(ctx.hold.scrip, 7 * price);
+ }
+}
diff --git a/src/scenes/saves.rs b/src/scenes/saves.rs
new file mode 100644
index 0000000..ca78eb2
--- /dev/null
+++ b/src/scenes/saves.rs
@@ -0,0 +1,278 @@
+//! The saved characters: pick one up where you left them, or delete them.
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Flex, Layout};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Padding, Paragraph};
+
+use super::flight;
+use super::forge::colour;
+use super::keys::{self, ACCENT, DIM};
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::save::{self, Slot};
+
+/// Lines each save takes in the list, the gap after it included.
+const ROWS_EACH: u16 = 3;
+
+pub struct Saves {
+ slots: Vec<Slot>,
+ selected: usize,
+ /// Asking whether to delete the selected save.
+ deleting: bool,
+ problem: Option<String>,
+}
+
+impl Saves {
+ pub fn new(ctx: &Ctx) -> Saves {
+ let slots = ctx.saves.as_deref().map(save::list).unwrap_or_default();
+ // Whoever you're playing is first, being the most recently saved.
+ Saves { slots, selected: 0, deleting: false, problem: None }
+ }
+
+ fn playing(&self, ctx: &Ctx, i: usize) -> bool {
+ ctx.slot.as_ref() == Some(&self.slots[i].path)
+ }
+
+ fn load(&mut self, ctx: &mut Ctx) -> Transition {
+ let Some(slot) = self.slots.get(self.selected) else { return Transition::None };
+ if let Err(why) = &slot.summary {
+ self.problem = Some(format!("That save can't be read: {why}"));
+ return Transition::None;
+ }
+ if !self.playing(ctx, self.selected) {
+ ctx.autosave();
+ if let Err(why) = save::load(&slot.path, ctx) {
+ self.problem = Some(format!("That save can't be read: {why}"));
+ return Transition::None;
+ }
+ }
+ Transition::Replace(flight::resume(ctx))
+ }
+
+ fn delete(&mut self, ctx: &mut Ctx) {
+ let path = self.slots[self.selected].path.clone();
+ if let Err(why) = save::delete(&path) {
+ self.problem = Some(format!("Couldn't delete it: {why}"));
+ return;
+ }
+ // Delete who you're playing and there's no one left to save.
+ if ctx.slot.as_ref() == Some(&path) {
+ ctx.slot = None;
+ }
+ self.slots.remove(self.selected);
+ self.selected = self.selected.min(self.slots.len().saturating_sub(1));
+ }
+}
+
+/// "just now", "5 minutes ago", "3 days ago".
+fn ago(seconds: u64) -> String {
+ let (n, unit) = match seconds {
+ 0..60 => return "just now".into(),
+ 60..3600 => (seconds / 60, "minute"),
+ 3600..86_400 => (seconds / 3600, "hour"),
+ _ => (seconds / 86_400, "day"),
+ };
+ match n {
+ 1 if unit == "hour" => "an hour ago".into(),
+ 1 => format!("a {unit} ago"),
+ _ => format!("{n} {unit}s ago"),
+ }
+}
+
+impl Scene for Saves {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ self.problem = None;
+ if self.deleting {
+ self.deleting = false;
+ if matches!(key.code, KeyCode::Char('y' | 'Y')) {
+ self.delete(ctx);
+ }
+ return Transition::None;
+ }
+ let last = self.slots.len().saturating_sub(1);
+ match key.code {
+ KeyCode::Esc | KeyCode::Char('q') => return Transition::Pop,
+ KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1),
+ KeyCode::Down | KeyCode::Char('j') => self.selected = (self.selected + 1).min(last),
+ KeyCode::Enter => return self.load(ctx),
+ KeyCode::Char('d') | KeyCode::Delete if !self.slots.is_empty() => self.deleting = true,
+ _ => {}
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let [area] =
+ Layout::horizontal([Constraint::Length(84)]).flex(Flex::Center).areas(frame.area());
+ let block = Block::bordered()
+ .border_style(Style::new().fg(DIM))
+ .title(Line::from(" SAVED CHARACTERS ").fg(ACCENT).bold())
+ .padding(Padding::uniform(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let [list, _, footer] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(1), Constraint::Length(2)])
+ .areas(inner);
+
+ let mut lines = Vec::new();
+ if self.slots.is_empty() {
+ lines.push(
+ Line::from("No one's saved yet. Make a new character from the title.").fg(DIM),
+ );
+ }
+ // Enough of the list to keep the selected save in view.
+ let shown = usize::from((list.height / ROWS_EACH).max(1));
+ let first = (self.selected + 1).saturating_sub(shown);
+ let now = save::now();
+ for (i, slot) in self.slots.iter().enumerate().skip(first).take(shown) {
+ let chosen = i == self.selected;
+ let marker = if chosen { Span::raw("› ").fg(ACCENT).bold() } else { Span::raw(" ") };
+ match &slot.summary {
+ Ok(s) => {
+ let c = &s.character;
+ let mut who = vec![
+ marker,
+ Span::raw(c.name.clone()).fg(colour(c.origin)).bold(),
+ Span::raw(format!(", {}, of {}", c.calling(), c.ship_name())),
+ ];
+ if self.playing(ctx, i) {
+ who.push(Span::raw(" playing").fg(ACCENT));
+ }
+ lines.push(Line::from(who));
+ let boarded = match s.record.boarded {
+ 1 => "1 wreck boarded".to_string(),
+ n => format!("{n} wrecks boarded"),
+ };
+ lines.push(
+ Line::from(format!(
+ " day {:.1} at {} · {} ¤ · {boarded} · saved {}",
+ s.day,
+ s.place,
+ s.scrip,
+ ago(now.saturating_sub(s.saved))
+ ))
+ .fg(DIM),
+ );
+ }
+ Err(why) => {
+ let file = slot.path.file_name().unwrap_or_default().to_string_lossy();
+ lines.push(Line::from(vec![marker, Span::raw(file.into_owned()).fg(DIM)]));
+ lines.push(Line::from(format!(" can't be read: {why}")).fg(Color::LightRed));
+ }
+ }
+ lines.push(Line::default());
+ }
+ frame.render_widget(Paragraph::new(lines), list);
+
+ let name = |i: usize| match &self.slots[i].summary {
+ Ok(s) => s.character.name.clone(),
+ Err(_) => "this save".into(),
+ };
+ let mut help = Vec::new();
+ if self.deleting {
+ let who = name(self.selected);
+ help.push(Line::from(format!("Delete {who} for good?")).fg(Color::LightRed));
+ help.push(keys::line(&[("y", "delete"), ("any other key", "keep them")]));
+ } else {
+ if let Some(problem) = &self.problem {
+ help.push(Line::from(problem.as_str()).fg(Color::LightRed));
+ } else if let Some(dir) = &ctx.saves {
+ help.push(Line::from(format!("saved in {}", dir.display())).fg(DIM));
+ }
+ help.push(keys::line(&[
+ ("↑↓", "choose"),
+ ("enter", "play"),
+ ("d", "delete"),
+ ("esc", "back"),
+ ]));
+ }
+ frame.render_widget(Paragraph::new(help), footer);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::names::Culture;
+ use crate::save::Character;
+ use crate::term::KeyboardCaps;
+
+ fn press(scene: &mut Saves, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ /// A saves folder with two characters in it, and the game playing the
+ /// second.
+ fn two(dir: &std::path::Path) -> Ctx {
+ let mut ctx = Ctx::new(1, KeyboardCaps { key_release: false });
+ ctx.saves = Some(dir.to_path_buf());
+ for (name, universe, scrip) in [("Ada Vell", 11, 300), ("Bram Oskel", 22, 5)] {
+ let character =
+ Character { name: name.into(), origin: Culture::Cant, ship: "Bolt".into() };
+ ctx.begin(universe, character);
+ ctx.slot = Some(save::new_slot(dir, &ctx.character, universe));
+ ctx.hold.scrip = scrip;
+ ctx.autosave();
+ assert!(ctx.save_error.is_none());
+ }
+ ctx
+ }
+
+ #[test]
+ fn loading_picks_up_where_they_left_off() {
+ let dir = save::scratch("saves-load");
+ let mut ctx = two(&dir);
+ let mut saves = Saves::new(&ctx);
+ assert_eq!(saves.slots.len(), 2);
+ // Both were saved within the same second, so either may come first.
+ let ada = saves
+ .slots
+ .iter()
+ .position(|s| s.summary.as_ref().unwrap().character.name == "Ada Vell")
+ .unwrap();
+ ctx.hold.scrip = 6;
+ saves.selected = ada;
+ assert!(matches!(press(&mut saves, &mut ctx, KeyCode::Enter), Transition::Replace(_)));
+ assert_eq!((ctx.character.name.as_str(), ctx.seed, ctx.hold.scrip), ("Ada Vell", 11, 300));
+
+ // Bram was saved as they were left, scrip and all.
+ let bram = save::new_slot(
+ &dir,
+ &Character { name: "Bram Oskel".into(), origin: Culture::Cant, ship: "Bolt".into() },
+ 22,
+ );
+ save::load(&bram, &mut ctx).unwrap();
+ assert_eq!(ctx.hold.scrip, 6);
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn deleting_asks_first() {
+ let dir = save::scratch("saves-delete");
+ let mut ctx = two(&dir);
+ let mut saves = Saves::new(&ctx);
+ press(&mut saves, &mut ctx, KeyCode::Char('d'));
+ press(&mut saves, &mut ctx, KeyCode::Char('n'));
+ assert_eq!(saves.slots.len(), 2, "anything but yes keeps it");
+ let playing = saves.playing(&ctx, 0);
+ press(&mut saves, &mut ctx, KeyCode::Char('d'));
+ press(&mut saves, &mut ctx, KeyCode::Char('y'));
+ assert_eq!((saves.slots.len(), save::list(&dir).len()), (1, 1));
+ assert_eq!(ctx.slot.is_none(), playing, "delete who you're playing and nothing's saved");
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn saved_times_read_naturally() {
+ assert_eq!(ago(5), "just now");
+ assert_eq!(ago(60), "a minute ago");
+ assert_eq!(ago(3 * 3600 + 5), "3 hours ago");
+ assert_eq!(ago(3600), "an hour ago");
+ assert_eq!(ago(86_400 * 2), "2 days ago");
+ }
+}
diff --git a/src/scenes/sky.rs b/src/scenes/sky.rs
new file mode 100644
index 0000000..8248dd0
--- /dev/null
+++ b/src/scenes/sky.rs
@@ -0,0 +1,460 @@
+//! Drawing space: starfields, lit planets, rocks, stations, wrecks and your
+//! ship, in terminal cells.
+//!
+//! A local map unit is one cell across and half a cell down (cells are
+//! about twice as tall as wide), so a circle of radius r spans 2r columns
+//! and r rows, and looks round.
+
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier};
+
+use crate::names::Culture;
+use crate::seed;
+use crate::space::Vec2;
+use crate::space::flight::Ship;
+use crate::space::system::Rgb;
+
+pub fn rgb((r, g, b): Rgb) -> Color {
+ Color::Rgb(r, g, b)
+}
+
+/// `colour` at `k` times its brightness (k from 0 to about 1.3).
+pub fn shade((r, g, b): Rgb, k: f64) -> Color {
+ let f = |c: u8| (f64::from(c) * k).round().clamp(0.0, 255.0) as u8;
+ Color::Rgb(f(r), f(g), f(b))
+}
+
+/// Between `a` (at k = 0) and `b` (at k = 1).
+pub fn mix(a: Rgb, b: Rgb, k: f64) -> Rgb {
+ let k = k.clamp(0.0, 1.0);
+ let f = |x: u8, y: u8| (f64::from(x) * (1.0 - k) + f64::from(y) * k).round() as u8;
+ (f(a.0, b.0), f(a.1, b.1), f(a.2, b.2))
+}
+
+/// A number from 0 to 1 that's always the same for the same inputs.
+pub fn hash01(seed: u64, x: i64, y: i64) -> f64 {
+ let h = seed::derive(seed, "", (x as u64) << 32 ^ (y as u64 & 0xffff_ffff));
+ (h >> 11) as f64 / (1u64 << 53) as f64
+}
+
+/// Where on screen a map position lands, with the camera in the middle of
+/// `area`. Fractional: the cell is the floor of each coordinate.
+#[derive(Clone, Copy)]
+pub struct View {
+ pub area: Rect,
+ pub camera: Vec2,
+}
+
+impl View {
+ pub fn screen(&self, p: Vec2) -> (f64, f64) {
+ let a = self.area;
+ let col = f64::from(a.x) + f64::from(a.width) / 2.0 + (p.x - self.camera.x);
+ let row = f64::from(a.y) + f64::from(a.height) / 2.0 + (p.y - self.camera.y) / 2.0;
+ (col, row)
+ }
+
+ /// The cell a map position falls in, if it's on screen.
+ pub fn cell(&self, p: Vec2) -> Option<(u16, u16)> {
+ let (col, row) = self.screen(p);
+ cell_in(self.area, col.floor(), row.floor())
+ }
+
+ pub fn put(&self, buf: &mut Buffer, p: Vec2, ch: char, fg: Color) {
+ if let Some(pos) = self.cell(p) {
+ buf[pos].set_char(ch).set_fg(fg);
+ }
+ }
+}
+
+fn cell_in(area: Rect, col: f64, row: f64) -> Option<(u16, u16)> {
+ let inside = col >= f64::from(area.x)
+ && row >= f64::from(area.y)
+ && col < f64::from(area.right())
+ && row < f64::from(area.bottom());
+ inside.then_some((col as u16, row as u16))
+}
+
+/// Writes `text` centred on a screen column, clipped to `area`.
+pub fn label(buf: &mut Buffer, area: Rect, col: f64, row: f64, text: &str, fg: Color) {
+ let width = text.chars().count() as f64;
+ let start = (col - width / 2.0).floor();
+ for (i, ch) in text.chars().enumerate() {
+ if let Some(pos) = cell_in(area, start + i as f64, row.floor()) {
+ buf[pos].set_char(ch).set_fg(fg);
+ }
+ }
+}
+
+/// Star layers: how fast each moves as the camera does, how rare its stars
+/// are, glyph, and colour. Slower layers read as further away.
+const STAR_LAYERS: [(f64, u32, char, Rgb); 3] = [
+ (0.04, 90, '.', (70, 72, 92)),
+ (0.12, 170, '·', (130, 132, 150)),
+ (0.3, 420, '+', (210, 210, 225)),
+];
+
+pub fn starfield(buf: &mut Buffer, area: Rect, camera: Vec2, seed: u64) {
+ for (layer, &(parallax, rarity, glyph, colour)) in STAR_LAYERS.iter().enumerate() {
+ let layer_seed = seed::derive(seed, "stars", layer as u64);
+ let dx = (camera.x * parallax).floor() as i64;
+ let dy = (camera.y * parallax / 2.0).floor() as i64;
+ for row in area.top()..area.bottom() {
+ for col in area.left()..area.right() {
+ let h = hash01(layer_seed, i64::from(col) + dx, i64::from(row) + dy);
+ if h * f64::from(rarity) < 1.0 {
+ buf[(col, row)].set_char(glyph).set_fg(rgb(colour));
+ }
+ }
+ }
+ }
+}
+
+/// How to paint a sphere.
+pub struct Surface {
+ pub rgb: Rgb,
+ /// Cloud bands, for giants.
+ pub banded: bool,
+ /// City lights on the night side.
+ pub lights: bool,
+ /// Glyphs from darkest to brightest.
+ pub ramp: &'static [char],
+ /// Varies the mottling and bands from one body to the next.
+ pub seed: u64,
+}
+
+pub const PLANET_RAMP: &[char] = &['·', '░', '▒', '▓', '█'];
+pub const ROCK_RAMP: &[char] = &['.', ':', '%', '#', '@'];
+
+/// A sphere centred at screen position (`col`, `row`), `radius` columns
+/// across, lit from `sun` (a unit vector on the map, y down). Returns
+/// whether it covered any cell.
+pub fn sphere(
+ buf: &mut Buffer,
+ area: Rect,
+ (col, row): (f64, f64),
+ radius: f64,
+ sun: Vec2,
+ surface: &Surface,
+) -> bool {
+ // Mostly from the side, a little from in front, so there's always a
+ // terminator to see.
+ let light = {
+ let (x, y, z) = (sun.x, sun.y, 0.45);
+ let len = (x * x + y * y + z * z).sqrt();
+ (x / len, y / len, z / len)
+ };
+ let top = (row - radius / 2.0).floor() as i64;
+ let bottom = (row + radius / 2.0).ceil() as i64;
+ let left = (col - radius).floor() as i64;
+ let right = (col + radius).ceil() as i64;
+ let mut drawn = false;
+ for r in top..=bottom {
+ for c in left..=right {
+ let nx = (c as f64 + 0.5 - col) / radius;
+ let ny = (r as f64 + 0.5 - row) * 2.0 / radius;
+ let d2 = nx * nx + ny * ny;
+ if d2 > 1.0 {
+ continue;
+ }
+ let Some(pos) = cell_in(area, c as f64, r as f64) else {
+ continue;
+ };
+ drawn = true;
+ let nz = (1.0 - d2).sqrt();
+ let lit = (nx * light.0 + ny * light.1 + nz * light.2).max(0.0);
+ let grain = |n: f64| {
+ let g = hash01(surface.seed, (nx * n).floor() as i64, (ny * n).floor() as i64);
+ 0.86 + 0.14 * g
+ };
+ let mut tone = grain(5.0);
+ if surface.banded {
+ let phase = (surface.seed % 628) as f64 / 100.0;
+ tone =
+ 0.84 + 0.12 * (ny * 7.0 + phase).sin() + 0.06 * (ny * 19.0 + phase * 2.0).sin();
+ }
+ let cell = &mut buf[pos];
+ if lit < 0.04 {
+ let city = surface.lights && grain(13.0) > 0.99 - 0.08;
+ if city {
+ cell.set_char('·').set_fg(Color::Rgb(255, 196, 110));
+ } else {
+ cell.set_char(surface.ramp[0]).set_fg(shade(surface.rgb, 0.16 * tone));
+ }
+ continue;
+ }
+ let steps = surface.ramp.len() - 1;
+ let glyph = surface.ramp[1 + ((lit * steps as f64) as usize).min(steps - 1)];
+ cell.set_char(glyph).set_fg(shade(surface.rgb, (0.2 + 0.95 * lit) * tone));
+ }
+ }
+ drawn
+}
+
+/// One glyph per eighth of a turn, for headings (0 is up, clockwise).
+pub fn arrow(heading: f64) -> char {
+ const ARROWS: [char; 8] = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖'];
+ let eighth = (heading / std::f64::consts::TAU * 8.0).round() as i64;
+ ARROWS[eighth.rem_euclid(8) as usize]
+}
+
+/// Each culture's station, drawn centred on its position.
+pub fn station_art(culture: Culture) -> (&'static [&'static str], Rgb) {
+ match culture {
+ Culture::Qaari => (&[" .-=-. ", "=(( o ))=", " '-=-' "], (232, 190, 112)),
+ Culture::Cant => (&[" /\\ ", "=[|##|]=", " |##| ", " \\/ "], (204, 124, 92)),
+ Culture::Houses => (&[" _/^^\\_ ", " <|-<>-|>", " \\__/ "], (206, 186, 240)),
+ Culture::Drift => (&[" =o= =o= ", "<==[ ]==>", " =o= =o= "], (124, 212, 200)),
+ }
+}
+
+pub fn draw_station(buf: &mut Buffer, view: &View, culture: Culture, ticks: u64) {
+ let (art, colour) = station_art(culture);
+ let (col, row) = view.screen(Vec2::ZERO);
+ let top = row - art.len() as f64 / 2.0;
+ for (i, line) in art.iter().enumerate() {
+ let width = line.chars().count() as f64;
+ let start = (col - width / 2.0).floor();
+ for (j, ch) in line.chars().enumerate() {
+ if ch == ' ' {
+ continue;
+ }
+ let Some(pos) = cell_in(view.area, start + j as f64, (top + i as f64).floor()) else {
+ continue;
+ };
+ // Running lights blink in turn.
+ let light = ch == 'o' && (ticks / 15 + j as u64).is_multiple_of(3);
+ let fg = if light { Color::Rgb(255, 240, 200) } else { rgb(colour) };
+ buf[pos].set_char(ch).set_fg(fg);
+ if light {
+ buf[pos].modifier.insert(Modifier::BOLD);
+ }
+ }
+ }
+}
+
+/// A wreck: a dark hull pointing along its heading, with a red distress
+/// beacon blinking if `beacon` (there's still anything aboard, and the
+/// beacon hasn't failed).
+pub fn draw_wreck(
+ buf: &mut Buffer,
+ view: &View,
+ pos: Vec2,
+ heading: f64,
+ beacon: bool,
+ ticks: u64,
+) {
+ let along = Vec2::from_heading(heading);
+ let hull = Color::Rgb(112, 118, 140);
+ view.put(buf, pos - along * 1.5, '=', hull);
+ view.put(buf, pos, '#', hull);
+ view.put(buf, pos + along * 1.5, arrow(heading), hull);
+ if beacon && ticks % 40 < 8 {
+ view.put(buf, pos, '#', Color::Rgb(255, 70, 60));
+ }
+}
+
+/// Your ship's outline, a notched dart: corners as (ahead, to the right)
+/// of its position, in map units. About five and a half units long.
+const HULL: [(f64, f64); 4] = [(2.75, 0.0), (-2.75, 2.1), (-0.75, 0.0), (-2.75, -2.1)];
+
+/// Where the engine flame starts: the notch at the back of the hull.
+const NOZZLE: f64 = -0.75;
+
+/// Braille dot bits by (column, row) within a cell.
+const BRAILLE: [[u32; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]];
+
+/// Your ship, turned to exactly its heading, with a flame out the back
+/// while the engine is lit. Drawn in braille dots, which are half a unit
+/// square (two across a cell, four down), so it turns smoothly rather than
+/// snapping between eight arrows.
+pub fn draw_ship(buf: &mut Buffer, view: &View, ship: &Ship, ticks: u64) {
+ let ahead = Vec2::from_heading(ship.heading);
+ let right = Vec2::new(-ahead.y, ahead.x);
+ let local = |p: Vec2| (p.dot(ahead), p.dot(right));
+ let (col, row) = view.screen(ship.pos);
+ if ship.thrusting {
+ // A plume, widest just behind the nozzle, flickering between short
+ // and long ten times a second. Its first unit or two is mostly
+ // hidden under the hull, which reads better than filling the notch.
+ let length = 4.0 + 2.0 * hash01(0, (ticks / 3) as i64, 0);
+ let flame = [
+ (NOZZLE, 0.5),
+ (NOZZLE - 1.5, 1.0),
+ (NOZZLE - length, 0.0),
+ (NOZZLE - 1.5, -1.0),
+ (NOZZLE, -0.5),
+ ];
+ for ((c, r), ch) in braille(view, ship.pos, 7.0, |p| inside(&flame, local(p))) {
+ // Yellow near the ship, cooling to red-orange at the tip.
+ let middle = Vec2::new(f64::from(c) + 0.5 - col, (f64::from(r) + 0.5 - row) * 2.0);
+ let k = (NOZZLE - local(middle).0) / length;
+ let colour = mix((255, 224, 130), (240, 80, 30), (k - 0.3) / 0.7);
+ buf[(c, r)].set_char(ch).set_fg(rgb(colour));
+ }
+ }
+ // The hull goes on top: where the two share a cell, the hull wins.
+ for (cell, ch) in braille(view, ship.pos, 3.5, |p| inside(&HULL, local(p))) {
+ buf[cell].set_char(ch).set_fg(Color::White);
+ }
+}
+
+/// The braille characters for a shape within `reach` map units of `pos`,
+/// and the cells they go in. `covers` says whether a point (in map units
+/// from `pos`) is inside the shape, and a dot is lit when the shape covers
+/// at least 40% of it, which keeps edges clean at every angle.
+fn braille(
+ view: &View,
+ pos: Vec2,
+ reach: f64,
+ covers: impl Fn(Vec2) -> bool,
+) -> Vec<((u16, u16), char)> {
+ // Samples per dot, each way.
+ const SAMPLES: u32 = 4;
+ let sub = |i: u32| (f64::from(i) + 0.5) / f64::from(SAMPLES);
+ let (col, row) = view.screen(pos);
+ let mut cells = Vec::new();
+ for r in (row - reach / 2.0).floor() as i64..=(row + reach / 2.0).floor() as i64 {
+ for c in (col - reach).floor() as i64..=(col + reach).floor() as i64 {
+ let Some(cell) = cell_in(view.area, c as f64, r as f64) else {
+ continue;
+ };
+ let mut bits = 0;
+ for (dx, column) in BRAILLE.iter().enumerate() {
+ for (dy, bit) in column.iter().enumerate() {
+ let mut hits = 0;
+ for sx in 0..SAMPLES {
+ for sy in 0..SAMPLES {
+ // In map units from `pos`: a column is one, a row two.
+ let x = c as f64 - col + (dx as f64 + sub(sx)) / 2.0;
+ let y = (r as f64 - row + (dy as f64 + sub(sy)) / 4.0) * 2.0;
+ hits += u32::from(covers(Vec2::new(x, y)));
+ }
+ }
+ if hits * 5 >= SAMPLES * SAMPLES * 2 {
+ bits |= bit;
+ }
+ }
+ }
+ if let Some(ch) = char::from_u32(0x2800 + bits).filter(|_| bits != 0) {
+ cells.push((cell, ch));
+ }
+ }
+ }
+ cells
+}
+
+/// Whether `point` is inside `polygon` (even-odd rule, so it may be concave).
+fn inside(polygon: &[(f64, f64)], (x, y): (f64, f64)) -> bool {
+ let mut odd = false;
+ for (i, &(x1, y1)) in polygon.iter().enumerate() {
+ let (x2, y2) = polygon[(i + 1) % polygon.len()];
+ if (y1 > y) != (y2 > y) && x < x1 + (y - y1) * (x2 - x1) / (y2 - y1) {
+ odd = !odd;
+ }
+ }
+ odd
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Every lit braille dot in `buf`, in map units from the centre of
+ /// `view`, as (ahead, to the right) for a ship facing `heading`.
+ fn ship_dots(buf: &Buffer, view: &View, heading: f64) -> Vec<(f64, f64)> {
+ let ahead = Vec2::from_heading(heading);
+ let right = Vec2::new(-ahead.y, ahead.x);
+ let (col, row) = view.screen(view.camera);
+ let mut dots = Vec::new();
+ for r in 0..view.area.height {
+ for c in 0..view.area.width {
+ let ch = buf[(c, r)].symbol().chars().next().unwrap_or(' ');
+ let bits = u32::from(ch).wrapping_sub(0x2800);
+ for (dx, column) in BRAILLE.iter().enumerate() {
+ for (dy, &bit) in column.iter().enumerate() {
+ if bits <= 0xff && bits & bit != 0 {
+ let x = f64::from(c) - col + (dx as f64 + 0.5) / 2.0;
+ let y = (f64::from(r) - row + (dy as f64 + 0.5) / 4.0) * 2.0;
+ let p = Vec2::new(x, y);
+ dots.push((p.dot(ahead), p.dot(right)));
+ }
+ }
+ }
+ }
+ }
+ dots
+ }
+
+ fn ship(heading: f64, thrusting: bool) -> Ship {
+ Ship { pos: Vec2::ZERO, vel: Vec2::ZERO, heading, thrusting }
+ }
+
+ #[test]
+ fn the_ship_points_its_nose_along_its_heading() {
+ let view = View { area: Rect::new(0, 0, 20, 10), camera: Vec2::ZERO };
+ for heading in [0.0, 1.0, 1.9, 2.5, 3.6, 4.4, 5.5] {
+ let mut buf = Buffer::empty(view.area);
+ draw_ship(&mut buf, &view, &ship(heading, false), 0);
+ let dots = ship_dots(&buf, &view, heading);
+ assert!((24..=36).contains(&dots.len()), "{heading}: {} dots", dots.len());
+ let front = dots.iter().filter(|d| d.0 > 1.25);
+ assert!(front.clone().count() >= 2, "{heading}: a nose");
+ assert!(front.clone().all(|d| d.1.abs() < 1.25), "{heading}: a narrow nose");
+ let wing = |side: f64| dots.iter().any(|d| d.0 < -1.5 && d.1 * side > 1.25);
+ assert!(wing(1.0) && wing(-1.0), "{heading}: wings behind, either side");
+ }
+ }
+
+ #[test]
+ fn a_lit_engine_trails_a_flame_out_the_back() {
+ let view = View { area: Rect::new(0, 0, 24, 12), camera: Vec2::ZERO };
+ for heading in [0.0, 2.0, 4.0] {
+ // The flame's dots, as how far ahead of the ship each one is.
+ let flame = |thrusting: bool, ticks: u64| {
+ let mut buf = Buffer::empty(view.area);
+ draw_ship(&mut buf, &view, &ship(heading, thrusting), ticks);
+ for cell in buf.content.iter_mut().filter(|c| c.fg == Color::White) {
+ cell.reset();
+ }
+ ship_dots(&buf, &view, heading)
+ };
+ assert!(flame(false, 0).is_empty(), "{heading}: no flame with the engine off");
+ let lit = flame(true, 0);
+ assert!(!lit.is_empty(), "{heading}: a flame with it on");
+ assert!(lit.iter().all(|d| d.0 < -1.0), "{heading}: only behind: {lit:?}");
+ let sizes: Vec<usize> = (0..60).step_by(3).map(|t| flame(true, t).len()).collect();
+ assert!(sizes.iter().min() < sizes.iter().max(), "{heading}: flickering");
+ }
+ }
+
+ #[test]
+ fn arrows_point_where_headings_do() {
+ use std::f64::consts::TAU;
+ assert_eq!(arrow(0.0), '↑');
+ assert_eq!(arrow(TAU / 4.0), '→');
+ assert_eq!(arrow(TAU - 0.1), '↑');
+ assert_eq!(arrow(-TAU / 8.0), '↖');
+ }
+
+ #[test]
+ fn spheres_are_round_and_lit_on_the_sunward_side() {
+ let area = Rect::new(0, 0, 40, 20);
+ let mut buf = Buffer::empty(area);
+ let surface = Surface {
+ rgb: (200, 200, 200),
+ banded: false,
+ lights: false,
+ ramp: PLANET_RAMP,
+ seed: 1,
+ };
+ assert!(sphere(&mut buf, area, (20.0, 10.0), 8.0, Vec2::new(1.0, 0.0), &surface));
+ let row: String = (0..40).map(|c| buf[(c, 10)].symbol().chars().next().unwrap()).collect();
+ let covered = row.chars().filter(|&c| c != ' ').count();
+ assert!((15..=17).contains(&covered), "8 units across each way: {row:?}");
+ let column: usize = (0..20).filter(|&r| buf[(20, r)].symbol() != " ").count();
+ assert!((7..=9).contains(&column), "and half that many rows tall");
+ assert_eq!(buf[(26, 10)].symbol(), "█", "bright to the east, where the sun is");
+ assert_eq!(buf[(13, 10)].symbol(), "·", "dark to the west");
+ }
+}
diff --git a/src/scenes/system_map.rs b/src/scenes/system_map.rs
new file mode 100644
index 0000000..65c6011
--- /dev/null
+++ b/src/scenes/system_map.rs
@@ -0,0 +1,830 @@
+//! The system map: the star, every orbit, where everything is today, and a
+//! list of places to travel to with how long each trip takes.
+//!
+//! Distances from the star are drawn on a square-root scale, so the inner
+//! planets aren't crushed into the middle and the outer ones still fit.
+//! Moons are too close to their planets to see at any scale that shows the
+//! whole system, so they're drawn just off their planets, in the right
+//! direction.
+//!
+//! Choosing a place shows where it will be when you get there: trips aim
+//! for that, not for where it is now, and a survey of what it's like.
+//!
+//! Every place is marked with what you'll find if you land or dock there:
+//! a capital city (⌂), a station that only trades (¤), or neither (·).
+//!
+//! `g` swaps this for the star map, to jump to another system.
+
+use std::f64::consts::TAU;
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style, Stylize};
+use ratatui::symbols::Marker;
+use ratatui::text::{Line, Span};
+use ratatui::widgets::canvas::{Canvas, Context, Points};
+use ratatui::widgets::{Block, Paragraph};
+
+use super::galaxy_map::GalaxyMap;
+use super::keys;
+use super::sky;
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::names::Culture;
+use crate::space::Vec2;
+use crate::space::orbit;
+use crate::space::survey::{self, FREEZING, Survey, Temperature};
+use crate::space::system::{Landing, PlaceKind, Rgb, System, Wreck};
+use crate::town;
+
+const PANEL_WIDTH: u16 = 46;
+/// Rows for what's known about the chosen place.
+const ABOUT_HEIGHT: u16 = 20;
+/// The labels down the left of the survey: "temp", "air".
+const LABEL_WIDTH: usize = 7;
+const ACCENT: Color = Color::Rgb(240, 200, 110);
+const DIM: Color = Color::Rgb(110, 114, 132);
+const ORBIT: Color = Color::Rgb(58, 62, 80);
+const CAPITAL: Color = Color::Rgb(130, 214, 140);
+const MARKET: Color = Color::Rgb(110, 196, 226);
+const WRECK: Color = Color::Rgb(255, 90, 80);
+/// A beacon that's failing, or will have failed by the time you get there.
+pub(crate) const FAINT_WRECK: Color = Color::Rgb(150, 76, 70);
+/// A beacon with this few days left is failing.
+pub(crate) const FAILING: f64 = 5.0;
+const SUIT_UP: Color = Color::Rgb(240, 150, 90);
+
+pub struct SystemMap {
+ selected: usize,
+}
+
+impl SystemMap {
+ pub fn new(ctx: &Ctx) -> SystemMap {
+ SystemMap { selected: ctx.location }
+ }
+
+ /// Days to get from where you are to `place`.
+ fn trip(ctx: &Ctx, place: usize) -> f64 {
+ let s = &ctx.system;
+ let from = s.position(ctx.location, ctx.day);
+ orbit::plan_trip(from, ctx.day, |d| s.position(place, d))
+ }
+}
+
+impl Scene for SystemMap {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ let n = ctx.system.places.len();
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k' | 'w') => self.selected = (self.selected + n - 1) % n,
+ KeyCode::Down | KeyCode::Char('j' | 's') => self.selected = (self.selected + 1) % n,
+ KeyCode::Enter | KeyCode::Char('t') => {
+ if self.selected != ctx.location {
+ let days = Self::trip(ctx, self.selected);
+ ctx.travel(self.selected, days);
+ }
+ return Transition::Pop;
+ }
+ KeyCode::Char('.') => ctx.day += 1.0,
+ KeyCode::Char('g') => return Transition::Replace(vec![Box::new(GalaxyMap::new(ctx))]),
+ KeyCode::Esc | KeyCode::Char('m' | 'q') => return Transition::Pop,
+ _ => {}
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let area = frame.area();
+ let (map, panel) = if area.width >= 100 {
+ let [map, panel] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
+ .areas(area);
+ (Some(map), panel)
+ } else {
+ (None, area)
+ };
+ if let Some(map) = map {
+ self.draw_orrery(frame, map, ctx);
+ }
+ self.draw_panel(frame, panel, ctx);
+ }
+}
+
+/// Positions in AU to canvas points, on a square-root scale out to `extent`.
+struct Scale {
+ radius: f64,
+ extent: f64,
+}
+
+impl Scale {
+ fn point(&self, p: Vec2) -> (f64, f64) {
+ let d = p.length();
+ if d == 0.0 {
+ return (0.0, 0.0);
+ }
+ let r = self.radius * (d / self.extent).sqrt();
+ (p.x / d * r, p.y / d * r)
+ }
+}
+
+impl SystemMap {
+ fn draw_orrery(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
+ let s = &ctx.system;
+ let day = ctx.day;
+ // One unit per braille dot: two across a cell, four down, and dots
+ // are about square.
+ let half_w = f64::from(area.width);
+ let half_h = 2.0 * f64::from(area.height);
+ let scale = Scale { radius: half_w.min(half_h) * 0.92, extent: s.extent() };
+
+ let trip = (self.selected != ctx.location).then(|| Self::trip(ctx, self.selected));
+ let canvas = Canvas::default()
+ .marker(Marker::Braille)
+ .background_color(Color::Reset)
+ .x_bounds([-half_w, half_w])
+ .y_bounds([-half_h, half_h])
+ .paint(|c| {
+ draw_orbits(c, s, &scale);
+ c.layer();
+ if let Some(days) = trip {
+ let from = scale.point(s.position(ctx.location, day));
+ let to = scale.point(s.position(self.selected, day + days));
+ let steps = ((to.0 - from.0).hypot(to.1 - from.1) / 3.0) as usize;
+ let dots: Vec<(f64, f64)> = (0..=steps)
+ .map(|i| {
+ let t = i as f64 / steps.max(1) as f64;
+ (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)
+ })
+ .collect();
+ c.draw(&Points { coords: &dots, color: ACCENT });
+ c.print(to.0, to.1, Span::raw("◌").fg(ACCENT));
+ }
+ self.draw_bodies(c, ctx, &scale);
+ });
+ frame.render_widget(canvas, area);
+ }
+
+ fn draw_bodies(&self, c: &mut Context, ctx: &Ctx, scale: &Scale) {
+ let s = &ctx.system;
+ let day = ctx.day;
+ c.print(0.0, 0.0, Span::raw("☼").fg(sky::rgb(s.star.rgb)).bold());
+ let mut labels = Vec::new();
+ for (i, place) in s.places.iter().enumerate() {
+ let here = i == ctx.location;
+ let chosen = i == self.selected;
+ let (x, y) = match place.kind {
+ PlaceKind::Planet(p) => {
+ let (x, y) = scale.point(s.planets[p].orbit.position(day));
+ let glyph = if here { "@" } else { "●" };
+ let colour = if here { Color::White } else { sky::rgb(s.planets[p].kind.rgb) };
+ c.print(x, y, Span::raw(glyph).fg(colour).bold());
+ if let Landing::Capital(_) = s.landing(i) {
+ c.print(x + 2.0, y, Span::raw("⌂").fg(CAPITAL));
+ }
+ (x, y)
+ }
+ PlaceKind::Moon(p, m) => {
+ let (px, py) = scale.point(s.planets[p].orbit.position(day));
+ let moon = &s.planets[p].moons[m];
+ let dir = moon.orbit.position(day).normalised();
+ let r = 4.0 + 2.5 * m as f64;
+ let (x, y) = (px + dir.x * r, py + dir.y * r);
+ let colour =
+ if here || chosen { Color::White } else { sky::rgb(moon.kind.rgb) };
+ if here {
+ c.print(x, y, Span::raw("@").fg(Color::White).bold());
+ } else if let Landing::Capital(_) = s.landing(i) {
+ c.print(
+ x,
+ y,
+ Span::raw("⌂").fg(if chosen { Color::White } else { CAPITAL }),
+ );
+ } else {
+ c.draw(&Points { coords: &[(x, y)], color: colour });
+ }
+ (x, y)
+ }
+ PlaceKind::Belt => {
+ let (x, y) = scale.point(s.belt.region.position(day));
+ if here {
+ c.print(x, y, Span::raw("@").fg(Color::White).bold());
+ } else if chosen {
+ c.print(x, y, Span::raw("+").fg(ACCENT));
+ }
+ (x, y)
+ }
+ PlaceKind::Station(k) => {
+ let (x, y) = scale.point(s.position(i, day));
+ let (_, colour) = sky::station_art(s.stations[k].culture);
+ let glyph = if here { "@" } else { "#" };
+ c.print(
+ x,
+ y,
+ Span::raw(glyph).fg(if here { Color::White } else { sky::rgb(colour) }),
+ );
+ (x, y)
+ }
+ };
+ if chosen || here {
+ let style =
+ if chosen { Style::new().fg(ACCENT) } else { Style::new().fg(Color::White) };
+ labels.push((x + 4.0, y, Span::styled(format!(" {}", place.name), style)));
+ }
+ }
+ // Names last, so nothing nearby is drawn over them.
+ for (x, y, label) in labels {
+ c.print(x, y, label);
+ }
+ }
+
+ fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
+ let s = &ctx.system;
+ let block = Block::bordered()
+ .title(format!(" the {} system · day {:.1} ", s.name, ctx.day))
+ .title_bottom(
+ Line::from(format!(
+ " {} of {} · fuel {}/{} ",
+ capitalise(s.kind.name()),
+ s.culture.name(),
+ ctx.hold.fuel,
+ ctx.hold.fuel_capacity
+ ))
+ .fg(DIM),
+ )
+ .title_style(Style::new().fg(ACCENT).bold())
+ .border_style(Style::new().fg(DIM));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let hints = keys::packed(
+ &[
+ ("↑↓", "choose"),
+ ("enter", "travel"),
+ (".", "wait a day"),
+ ("g", "star map"),
+ ("esc", "back"),
+ ],
+ inner.width,
+ );
+ let [list, about, keys] = Layout::vertical([
+ Constraint::Min(3),
+ Constraint::Length(ABOUT_HEIGHT),
+ Constraint::Length(1 + hints.len() as u16),
+ ])
+ .areas(inner);
+
+ // Scroll to keep the choice in view.
+ let rows = usize::from(list.height);
+ let first = self
+ .selected
+ .saturating_sub(rows.saturating_sub(1) / 2)
+ .min(s.places.len().saturating_sub(rows));
+ let wrecks = ctx.wrecks();
+ let lines: Vec<Line> = s
+ .places
+ .iter()
+ .enumerate()
+ .skip(first)
+ .take(rows)
+ .map(|(i, place)| {
+ let indent = match place.kind {
+ PlaceKind::Moon(..) | PlaceKind::Station(_) => " ",
+ _ => "",
+ };
+ let (mark, colour) = mark(s.landing(i));
+ let days = if i == ctx.location { 0.0 } else { Self::trip(ctx, i) };
+ let trip = if i == ctx.location {
+ "you're here".to_string()
+ } else {
+ format!("{days:.1} d")
+ };
+ // Bright if there's a wreck with time to spare; faint if all
+ // that's there are beacons on their last legs, which wait
+ // only if you go straight there.
+ let here = wrecks.iter().filter(|w| w.place == i);
+ let worth = here.clone().any(|w| w.leaves - ctx.day > FAILING);
+ let beacon = match (here.count(), worth) {
+ (0, _) => Span::raw(" "),
+ (_, true) => Span::raw("!").fg(WRECK),
+ (_, false) => Span::raw("!").fg(FAINT_WRECK),
+ };
+ let name_width = usize::from(list.width).saturating_sub(16 + indent.len());
+ let name: String = place.name.chars().take(name_width).collect();
+ let mut spans = vec![
+ Span::raw(format!(" {mark} ")).fg(colour),
+ Span::raw(format!("{indent}{name:<name_width$}")),
+ beacon,
+ Span::raw(format!("{trip:>12}")).fg(DIM),
+ ];
+ if i == self.selected {
+ for span in &mut spans {
+ span.style = span.style.add_modifier(Modifier::REVERSED);
+ }
+ }
+ Line::from(spans)
+ })
+ .collect();
+ frame.render_widget(Paragraph::new(lines), list);
+
+ frame.render_widget(
+ // Wrapped already, so that wrapped lines stay indented.
+ Paragraph::new(self.about(ctx, usize::from(about.width))),
+ about,
+ );
+ let legend = Line::from(vec![
+ Span::raw("⌂").fg(CAPITAL),
+ Span::raw(" capital ").fg(DIM),
+ Span::raw("¤").fg(MARKET),
+ Span::raw(" trade only ").fg(DIM),
+ Span::raw("·").fg(DIM),
+ Span::raw(" neither ").fg(DIM),
+ Span::raw("!").fg(WRECK),
+ Span::raw(" wreck").fg(DIM),
+ ]);
+ let mut lines = vec![legend];
+ lines.extend(hints);
+ frame.render_widget(Paragraph::new(lines), keys);
+ }
+
+ /// What's known about the chosen place: what it is, what you'll find if
+ /// you land, the trip there, and a survey of what it's like.
+ fn about(&self, ctx: &Ctx, width: usize) -> Vec<Line<'static>> {
+ let s = &ctx.system;
+ let i = self.selected;
+ let place = &s.places[i];
+ let who = |people: Option<Culture>, home: bool| match people {
+ Some(c) if home && s.kind != crate::space::system::SystemKind::Seat => {
+ format!("chief world of {}", c.name())
+ }
+ Some(c) if home => format!("homeworld of {}", c.name()),
+ Some(c) => format!("settled by {}", c.name()),
+ None => "uninhabited".to_string(),
+ };
+ let (what, orbit_line) = match place.kind {
+ PlaceKind::Planet(p) => {
+ let planet = &s.planets[p];
+ (
+ format!("{} · {}", planet.kind.name, who(planet.people, p == s.home)),
+ format!(
+ "{:.2} AU from the star; a year of {:.0} days",
+ planet.orbit.a, planet.orbit.period
+ ),
+ )
+ }
+ PlaceKind::Moon(p, m) => {
+ let moon = &s.planets[p].moons[m];
+ (
+ format!("{} · {}", moon.kind.name, who(moon.people, false)),
+ format!(
+ "a moon of {}, going round every {:.1} days",
+ s.planets[p].name, moon.orbit.period
+ ),
+ )
+ }
+ PlaceKind::Belt => (
+ "an asteroid belt: good mining".to_string(),
+ format!("from {:.2} to {:.2} AU from the star", s.belt.inner, s.belt.outer),
+ ),
+ PlaceKind::Station(k) => {
+ let st = &s.stations[k];
+ let point = if st.leading { "L4" } else { "L5" };
+ (
+ format!("a {} station", st.culture.adjective()),
+ format!(
+ "at {}'s {point} point, sixty degrees {} it",
+ s.planets[st.planet].name,
+ if st.leading { "ahead of" } else { "behind" }
+ ),
+ )
+ }
+ };
+ let prose = |text: String, style: Style| wrap(Vec::new(), &[(text, style)], width);
+ let mut lines = vec![Line::from(place.name.clone()).fg(ACCENT).bold()];
+ lines.extend(prose(what, Style::new()));
+ lines.extend(landing_line(s, i, width));
+ if i != ctx.location {
+ let days = Self::trip(ctx, i);
+ let now = s.position(ctx.location, ctx.day);
+ let there = s.position(i, ctx.day + days);
+ lines.extend(prose(
+ format!(
+ "{:.2} AU away by the time you arrive: {:.1} days, on day {:.1}",
+ now.distance(there),
+ days,
+ ctx.day + days
+ ),
+ Style::new(),
+ ));
+ }
+ let arrival = if i == ctx.location { ctx.day } else { ctx.day + Self::trip(ctx, i) };
+ for w in ctx.wrecks().iter().filter(|w| w.place == i) {
+ let found = if w.calling(ctx.day) {
+ format!(
+ "A distress beacon: {}, a {}. {}",
+ w.name,
+ w.description,
+ beacon_age(w, ctx.day)
+ )
+ } else {
+ format!(
+ "A wreck: {}, a {}. Its beacon failed on your way here, but you came by \
+ its last fix.",
+ w.name, w.description
+ )
+ };
+ let mut phrases = vec![(found, Style::new().fg(Color::Rgb(255, 120, 100)))];
+ if let Some(warning) = beacon_warning(w, ctx.day, arrival) {
+ phrases.push((format!(" {warning}"), Style::new().fg(FAINT_WRECK)));
+ }
+ lines.extend(wrap(Vec::new(), &phrases, width));
+ }
+ lines.push(Line::default());
+ let station = matches!(place.kind, PlaceKind::Station(_));
+ let giant = match place.kind {
+ PlaceKind::Planet(p) => s.planets[p].kind.giant,
+ _ => false,
+ };
+ lines.extend(facts(&survey::survey(s, i, arrival), station, giant, width));
+ lines.push(Line::default());
+ lines.extend(prose(orbit_line, Style::new().fg(DIM)));
+ lines
+ }
+}
+
+/// How long a wreck's beacon has been calling: "Calling for 12 days."
+pub(crate) fn beacon_age(w: &Wreck, day: f64) -> String {
+ match (day - w.arrived).floor() {
+ ..1.0 => "It started calling today.".into(),
+ 1.0 => "Calling since yesterday.".into(),
+ n => format!("Calling for {n:.0} days."),
+ }
+}
+
+/// Whether a beacon is failing, or will fail before you `arrive`. A wreck
+/// whose beacon has failed already is one you came by its last fix: there's
+/// nothing to warn of.
+pub(crate) fn beacon_warning(w: &Wreck, day: f64, arrive: f64) -> Option<String> {
+ let left = w.leaves - day;
+ if left <= 0.0 {
+ None
+ } else if w.leaves <= arrive {
+ Some(
+ "It'll fail on the way, but go straight there and you'll find it by its last fix."
+ .into(),
+ )
+ } else if left <= FAILING {
+ Some(match left.floor() {
+ ..1.0 => "Failing: hours left.".into(),
+ 1.0 => "Failing: a day left, maybe two.".into(),
+ n => format!("Failing: {n:.0} days left, maybe less."),
+ })
+ } else {
+ None
+ }
+}
+
+/// How a place is marked in the list: what you'll find if you land.
+fn mark(landing: Landing) -> (&'static str, Color) {
+ match landing {
+ Landing::Capital(_) => ("⌂", CAPITAL),
+ Landing::Market(_) => ("¤", MARKET),
+ Landing::Aloft(_) | Landing::Wilds | Landing::Nowhere => ("·", DIM),
+ }
+}
+
+/// What you'll find if you land or dock at a place, in a sentence.
+fn landing_line(s: &System, place: usize, width: usize) -> Vec<Line<'static>> {
+ let landing = s.landing(place);
+ let (mark, colour) = mark(landing);
+ let (tag, rest) = match landing {
+ Landing::Capital(c) => (
+ "Capital",
+ format!(
+ ": {}. Land and walk in to its broker, doctor, scholar, tavern and shipyard, \
+ which sells fuel.",
+ town::name(town::seed_for(s.seed, place), c)
+ ),
+ ),
+ Landing::Market(_) => {
+ ("Trade only", ": dock to sell ore and buy fuel. No city, no shops.".to_string())
+ }
+ Landing::Aloft(c) => (
+ "Neither",
+ format!(": {} live up in its clouds, but there's no ground to land on.", c.name()),
+ ),
+ Landing::Wilds => {
+ ("Neither", ": no one lives here to trade with. Land and look around.".to_string())
+ }
+ Landing::Nowhere if s.places[place].kind == PlaceKind::Belt => {
+ ("Neither", ": nowhere to land, only rocks to mine.".to_string())
+ }
+ Landing::Nowhere => {
+ ("Neither", ": no one lives here, and there's no ground to land on.".to_string())
+ }
+ };
+ let head = vec![Span::raw(format!("{mark} ")).fg(colour)];
+ let phrases = [(format!("{tag}{rest}"), Style::new())];
+ let mut lines = wrap(head, &phrases, width);
+ // Colour the tag, which is always the first word or two.
+ if let Some(first) = lines.first_mut() {
+ let words = tag.split(' ').count();
+ for span in first.spans.iter_mut().skip(1).step_by(2).take(words) {
+ span.style = Style::new().fg(colour).bold();
+ }
+ }
+ lines
+}
+
+/// The survey, as labelled rows.
+fn facts(survey: &Survey, station: bool, giant: bool, width: usize) -> Vec<Line<'static>> {
+ let plain = Style::new();
+ let dim = Style::new().fg(DIM);
+ let mut lines = Vec::new();
+ if let Some(t) = survey.temperature {
+ lines.push(thermometer(t));
+ let range = (t.hottest - t.coldest >= 2.0).then(|| {
+ format!("{} to {} °C", survey::celsius(t.coldest), survey::celsius(t.hottest))
+ });
+ let detail = match (giant, range) {
+ (true, Some(range)) => Some(format!("{range} at the cloud tops")),
+ (true, None) => Some("at the cloud tops".to_string()),
+ (false, Some(range)) => Some(format!("{range} over its year")),
+ (false, None) => None,
+ };
+ if let Some(detail) = detail {
+ lines.extend(fact("", &[(detail, dim)], width));
+ }
+ }
+ if let Some(day) = &survey.day {
+ let mut text = day.clone();
+ if let Some(seasons) = survey.seasons {
+ text = format!("{text}; {seasons}");
+ }
+ lines.extend(fact(if station { "spin" } else { "day" }, &[(text, plain)], width));
+ }
+ if let Some(g) = survey.gravity {
+ let amount = if g < 0.1 { format!("{g:.2} g") } else { format!("{g:.1} g") };
+ let feel = match g {
+ g if g < 0.3 => "featherlight",
+ g if g < 0.75 => "light",
+ g if g < 1.25 => "",
+ g if g < 1.8 => "heavy",
+ _ => "crushing",
+ };
+ let mut parts = vec![(amount, plain)];
+ if !feel.is_empty() {
+ parts.push((format!("· {feel}"), dim));
+ }
+ if station {
+ parts.push(("· from its spin".to_string(), dim));
+ }
+ lines.extend(fact("pull", &parts, width));
+ }
+ let suit_up = ("suit up".to_string(), Style::new().fg(SUIT_UP));
+ let air = match survey.air {
+ Some((what, true)) => {
+ vec![
+ (what.to_string(), plain),
+ ("·".into(), dim),
+ ("breathable".into(), Style::new().fg(CAPITAL)),
+ ]
+ }
+ Some((what, false)) => vec![(what.to_string(), plain), ("·".into(), dim), suit_up],
+ None => vec![("none".to_string(), plain), ("·".into(), dim), suit_up],
+ };
+ lines.extend(fact("air", &air, width));
+ lines.extend(fact("sky", &[(survey.sky.clone(), plain)], width));
+ if let Some(n) = survey.population {
+ let mut text = headcount(n);
+ if giant {
+ text.push_str(", in cloud cities");
+ }
+ lines.extend(fact(if station { "aboard" } else { "people" }, &[(text, plain)], width));
+ }
+ lines
+}
+
+/// A labelled row of the survey.
+fn fact(label: &str, phrases: &[(String, Style)], width: usize) -> Vec<Line<'static>> {
+ let head = vec![Span::raw(format!("{label:<LABEL_WIDTH$}")).fg(DIM)];
+ wrap(head, phrases, width)
+}
+
+/// Phrases joined by spaces, each in its own style, after `head` and
+/// word-wrapped to `width`, with the overflow indented to line up under the
+/// first line's text. Each word is its own span, after the head.
+pub(crate) fn wrap(
+ head: Vec<Span<'static>>,
+ phrases: &[(String, Style)],
+ width: usize,
+) -> Vec<Line<'static>> {
+ let indent: usize = head.iter().map(|s| s.content.chars().count()).sum();
+ let room = width.saturating_sub(indent).max(10);
+ let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()];
+ let mut used = 0;
+ for (text, style) in phrases {
+ for word in text.split_whitespace() {
+ let w = word.chars().count();
+ if used > 0 && used + 1 + w > room {
+ rows.push(Vec::new());
+ used = 0;
+ }
+ let row = rows.last_mut().unwrap();
+ if used > 0 {
+ row.push(Span::raw(" "));
+ used += 1;
+ }
+ row.push(Span::styled(word.to_string(), *style));
+ used += w;
+ }
+ }
+ let mut head = Some(head);
+ rows.into_iter()
+ .map(|spans| {
+ let mut line = head.take().unwrap_or_else(|| vec![Span::raw(" ".repeat(indent))]);
+ line.extend(spans);
+ Line::from(line)
+ })
+ .collect()
+}
+
+fn capitalise(s: &str) -> String {
+ let mut chars = s.chars();
+ chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect())
+}
+
+/// Cells in the thermometer.
+const THERMOMETER: usize = 14;
+/// The thermometer's ends, in kelvin. It's marked on a log scale, so the
+/// temperate middle isn't squeezed.
+const COLDEST: f64 = 50.0;
+const HOTTEST: f64 = 1500.0;
+
+/// A thermometer from deep cold to molten, filled up to the temperature
+/// now and lit more faintly over the rest of its year's swing; then the
+/// temperature and a word for it.
+fn thermometer(t: Temperature) -> Line<'static> {
+ let cell = |kelvin: f64| {
+ let k = (kelvin / COLDEST).ln() / (HOTTEST / COLDEST).ln();
+ (k.clamp(0.0, 1.0) * (THERMOMETER - 1) as f64).round() as usize
+ };
+ let (low, high, now) = (cell(t.coldest), cell(t.hottest), cell(t.now));
+ let mut spans = vec![Span::raw(format!("{:<LABEL_WIDTH$}", "temp")).fg(DIM)];
+ for i in 0..THERMOMETER {
+ let kelvin = COLDEST * (HOTTEST / COLDEST).powf(i as f64 / (THERMOMETER - 1) as f64);
+ let colour = heat(kelvin);
+ spans.push(if i == now {
+ Span::raw("●").fg(Color::White).bold()
+ } else if i < now {
+ Span::raw("━").fg(sky::rgb(colour))
+ } else if (low..=high).contains(&i) {
+ Span::raw("━").fg(sky::shade(colour, 0.6))
+ } else {
+ Span::raw("─").fg(sky::shade(colour, 0.35))
+ });
+ }
+ let colour = sky::rgb(heat(t.now));
+ spans.push(Span::raw(format!(" {} °C ", survey::celsius(t.now))).fg(colour).bold());
+ spans.push(Span::raw(t.word()).fg(colour));
+ Line::from(spans)
+}
+
+/// The colour of a temperature: blue through ice-white and green to red.
+fn heat(kelvin: f64) -> Rgb {
+ const STOPS: [(f64, Rgb); 7] = [
+ (50.0, (96, 112, 236)),
+ (150.0, (120, 178, 255)),
+ (240.0, (176, 226, 244)),
+ (FREEZING + 15.0, (128, 218, 138)),
+ (320.0, (240, 210, 96)),
+ (450.0, (246, 142, 64)),
+ (900.0, (236, 72, 52)),
+ ];
+ let k = kelvin.clamp(STOPS[0].0, STOPS[STOPS.len() - 1].0);
+ let i = STOPS.iter().rposition(|&(at, _)| at <= k).unwrap().min(STOPS.len() - 2);
+ let ((a, ca), (b, cb)) = (STOPS[i], STOPS[i + 1]);
+ let f = ((k / a).ln() / (b / a).ln()).clamp(0.0, 1.0);
+ let mix = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * f).round() as u8;
+ (mix(ca.0, cb.0), mix(ca.1, cb.1), mix(ca.2, cb.2))
+}
+
+/// "2.1 billion", "48 million", "12,000".
+fn headcount(n: u64) -> String {
+ let short = |x: f64, unit: &str| {
+ let text = if x < 10.0 { format!("{x:.1}") } else { format!("{x:.0}") };
+ format!("{} {unit}", text.trim_end_matches(".0"))
+ };
+ match n {
+ n if n >= 1_000_000_000 => short(n as f64 / 1e9, "billion"),
+ n if n >= 1_000_000 => short(n as f64 / 1e6, "million"),
+ n => {
+ let digits = n.to_string();
+ let mut out = String::new();
+ for (i, ch) in digits.chars().enumerate() {
+ if i > 0 && (digits.len() - i) % 3 == 0 {
+ out.push(',');
+ }
+ out.push(ch);
+ }
+ out
+ }
+ }
+}
+
+fn draw_orbits(c: &mut Context, s: &System, scale: &Scale) {
+ for planet in &s.planets {
+ let points: Vec<(f64, f64)> = planet.orbit.outline(240).map(|p| scale.point(p)).collect();
+ c.draw(&Points { coords: &points, color: ORBIT });
+ }
+ // The belt: a scatter of dots, the same every time.
+ let belt: Vec<(f64, f64)> = (0..700)
+ .map(|i| {
+ let angle = sky::hash01(s.seed, i, 1) * TAU;
+ let r = s.belt.inner + (s.belt.outer - s.belt.inner) * sky::hash01(s.seed, i, 2);
+ scale.point(Vec2::new(r * angle.cos(), r * angle.sin()))
+ })
+ .collect();
+ c.draw(&Points { coords: &belt, color: Color::Rgb(104, 96, 86) });
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::scenes::tests::test_ctx;
+
+ fn press(map: &mut SystemMap, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ #[test]
+ fn travelling_takes_days_and_moves_you() {
+ let mut ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ let start = ctx.location;
+ press(&mut map, &mut ctx, KeyCode::Down);
+ let destination = map.selected;
+ let days = SystemMap::trip(&ctx, destination);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop));
+ assert_eq!(ctx.location, destination);
+ assert_ne!(ctx.location, start);
+ assert!((ctx.day - days).abs() < 1e-9 && days > 0.0);
+ assert_eq!(ctx.departed, Some(0.0));
+ }
+
+ #[test]
+ fn a_beacon_that_fails_on_the_way_still_leads_you_there() {
+ let ctx = test_ctx();
+ let w = ctx.system.wrecks_on(0.0)[0].clone();
+ let warning = beacon_warning(&w, 0.0, w.leaves + 1.0).unwrap();
+ assert!(warning.contains("last fix"), "{warning}");
+ assert_eq!(beacon_warning(&w, 0.0, 0.5), None, "plenty of time");
+ let failed = w.leaves + 1.0;
+ assert_eq!(beacon_warning(&w, failed, failed), None, "you're there already");
+ }
+
+ #[test]
+ fn choosing_where_you_are_goes_nowhere() {
+ let mut ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ press(&mut map, &mut ctx, KeyCode::Enter);
+ assert_eq!(ctx.day, 0.0);
+ press(&mut map, &mut ctx, KeyCode::Char('.'));
+ assert_eq!(ctx.day, 1.0, "but you can wait");
+ }
+
+ #[test]
+ fn numbers_read_naturally() {
+ assert_eq!(headcount(2_100_000_000), "2.1 billion");
+ assert_eq!(headcount(3_000_000_000), "3 billion");
+ assert_eq!(headcount(48_000_000), "48 million");
+ assert_eq!(headcount(12_000), "12,000");
+ assert_eq!(headcount(870), "870");
+ }
+
+ #[test]
+ fn the_map_says_where_the_capitals_are() {
+ let ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ map.selected = ctx.system.home_place();
+ let text: String = map
+ .about(&ctx, 44)
+ .iter()
+ .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
+ .collect();
+ assert!(text.contains("⌂ Capital: "), "{text}");
+ assert!(text.contains("°C"), "{text}");
+ let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap();
+ assert_eq!(mark(ctx.system.landing(belt)).0, "·");
+ }
+
+ #[test]
+ fn the_list_wraps_round() {
+ let mut ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ map.selected = 0;
+ press(&mut map, &mut ctx, KeyCode::Up);
+ assert_eq!(map.selected, ctx.system.places.len() - 1);
+ }
+}
diff --git a/src/scenes/title.rs b/src/scenes/title.rs
index 7c75888..898452e 100644
--- a/src/scenes/title.rs
+++ b/src/scenes/title.rs
@@ -1,4 +1,6 @@
-//! Title screen: a turning orrery over a drifting starfield.
+//! Title screen: a turning orrery over a drifting starfield, and the way in:
+//! carry on as the character you last played, make a new one, or load
+//! another.
//!
//! Every universe seed gets its own orrery, and everything here moves as a
//! function of `ctx.ticks`, so it doubles as a check that the tick loop runs.
@@ -16,10 +18,15 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::widgets::canvas::{Canvas, Points};
+use super::character::{Create, Sheet};
use super::derelict::Derelict;
+use super::flight;
use super::forge::Forge;
+use super::keys::ACCENT;
+use super::saves::Saves;
use super::{Scene, Transition};
use crate::app::{Ctx, TICKS_PER_SECOND};
+use crate::save;
use crate::seed;
const LOGO: [&str; 4] = [
@@ -64,8 +71,13 @@ struct Planet {
}
pub struct Title {
+ /// Whose orrery this is. Loading or making a character can change
+ /// universe under it.
+ universe: u64,
stars: Vec<Star>,
planets: Vec<Planet>,
+ /// Whether there are saved characters to load.
+ saved: bool,
}
impl Title {
@@ -92,7 +104,8 @@ impl Title {
}
})
.collect();
- Title { stars, planets }
+ let saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty());
+ Title { universe: ctx.seed, stars, planets, saved }
}
fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) {
@@ -143,16 +156,43 @@ impl Title {
}
}
+/// The menu: key, what it does.
+fn menu(ctx: &Ctx, saved: bool) -> Vec<(&'static str, String)> {
+ let mut items = Vec::new();
+ if ctx.slot.is_some() {
+ let c = &ctx.character;
+ items.push(("enter", format!("carry on as {}, of {}", c.name, c.ship_name())));
+ items.push(("c", "your character".into()));
+ }
+ items.push(("n", "a new character".into()));
+ if saved {
+ items.push(("l", "load a character".into()));
+ }
+ if ctx.slot.is_some() {
+ items.push(("d", "board a derelict, straight away".into()));
+ }
+ items.push(("g", "the name forge".into()));
+ items.push(("q", "leave".into()));
+ items
+}
+
impl Scene for Title {
fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ let playing = ctx.slot.is_some();
match key.code {
+ KeyCode::Enter if playing => Transition::Open(flight::resume(ctx)),
+ KeyCode::Enter => Transition::Push(Box::new(Create::new(ctx))),
KeyCode::Char(c) => match c.to_ascii_lowercase() {
- 'd' => {
+ 'f' if playing => Transition::Open(flight::resume(ctx)),
+ 'c' if playing => Transition::Push(Box::new(Sheet::new())),
+ 'n' => Transition::Push(Box::new(Create::new(ctx))),
+ 'l' if self.saved => Transition::Push(Box::new(Saves::new(ctx))),
+ 'd' if playing => {
ctx.boarded += 1;
let ship = seed::derive(ctx.seed, "derelict", ctx.boarded);
- Transition::Push(Box::new(Derelict::new(ship)))
+ Transition::Push(Box::new(Derelict::board(ship, ctx)))
}
- 'n' => Transition::Push(Box::new(Forge::new(ctx))),
+ 'g' => Transition::Push(Box::new(Forge::new(ctx))),
'q' => Transition::Quit,
_ => Transition::None,
},
@@ -161,17 +201,26 @@ impl Scene for Title {
}
}
+ fn on_top(&mut self, ctx: &Ctx) {
+ if ctx.seed != self.universe {
+ *self = Title::new(ctx);
+ } else {
+ self.saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty());
+ }
+ }
+
fn render(&self, frame: &mut Frame, ctx: &Ctx) {
let seconds = ctx.ticks as f64 / f64::from(TICKS_PER_SECOND);
let area = frame.area();
self.draw_stars(frame.buffer_mut(), area, seconds);
+ let items = menu(ctx, self.saved);
let [sky, logo, _, menu, hold, footer] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(LOGO.len() as u16),
Constraint::Length(1),
- Constraint::Length(4),
- Constraint::Length(1),
+ Constraint::Length(items.len() as u16),
+ Constraint::Length(2),
Constraint::Length(1),
])
.areas(area);
@@ -181,44 +230,95 @@ impl Scene for Title {
let logo_lines: Vec<Line> = LOGO.iter().map(|l| Line::from(*l).bold()).collect();
frame.render_widget(Paragraph::new(logo_lines).centered().fg(Color::Yellow), logo);
- let item = |key: &'static str, label: &'static str, built: bool| {
- let style = if built { Style::new() } else { Style::new().fg(Color::DarkGray) };
- let mut spans = vec![Span::styled(key, style.bold()), Span::styled(label, style)];
- if !built {
- spans.push(Span::styled(" (not yet built)", style.italic()));
- }
- Line::from(spans)
- };
- let [menu] = Layout::horizontal([Constraint::Length(46)]).flex(Flex::Center).areas(menu);
- frame.render_widget(
- Paragraph::new(vec![
- item("[d] ", "board a derelict", true),
- item("[n] ", "the name forge", true),
- item("[f] ", "fly a star system", false),
- item("[q] ", "leave", true),
- ]),
- menu,
- );
-
- let h = &ctx.hold;
- if !h.artifacts.is_empty() || h.scrip > 0 {
- let plural = if h.artifacts.len() == 1 { "" } else { "s" };
+ // Keys in a column, in the colour key hints are everywhere.
+ let keys = items.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0) + 2;
+ let width = items.iter().map(|(_, l)| keys + l.chars().count()).max();
+ let [menu] = Layout::horizontal([Constraint::Length(width.unwrap_or(0) as u16)])
+ .flex(Flex::Center)
+ .areas(menu);
+ let lines: Vec<Line> = items
+ .into_iter()
+ .map(|(key, label)| {
+ Line::from(vec![
+ Span::raw(format!("{key:<keys$}")).fg(ACCENT).bold(),
+ Span::raw(label),
+ ])
+ })
+ .collect();
+ frame.render_widget(Paragraph::new(lines), menu);
+
+ if ctx.slot.is_some() {
+ let h = &ctx.hold;
+ let plural = if h.artifacts() == 1 { "" } else { "s" };
+ let place = match &ctx.crawl {
+ Some(c) => format!("aboard {}", c.name),
+ None => format!("at {}", ctx.system.places[ctx.location].name),
+ };
let text = format!(
- "your hold: {} artifact{plural}, {} ¤ of salvage",
- h.artifacts.len(),
+ "day {:.1} {place} in {} · {} artifact{plural}, {} t of ore, {} ¤",
+ ctx.day,
+ ctx.system.name,
+ h.artifacts(),
+ h.ore_tons(),
h.scrip
);
+ let [_, hold] = Layout::vertical([Constraint::Length(1); 2]).areas(hold);
frame.render_widget(Paragraph::new(text).centered().fg(Color::Yellow), hold);
}
- let keys = if ctx.keyboard.key_release {
- "held keys detectable (kitty keyboard protocol)"
- } else {
- "held keys not detectable: flight will need a throttle"
+ let footer_text = match &ctx.save_error {
+ Some(problem) => Paragraph::new(problem.as_str()).centered().light_red(),
+ None => {
+ let keys = if ctx.keyboard.key_release {
+ "held keys detectable (kitty keyboard protocol)"
+ } else {
+ "held keys not detectable: flight will need a throttle"
+ };
+ Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray()
+ }
+ };
+ frame.render_widget(footer_text, footer);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::scenes::tests::test_ctx;
+
+ fn press(title: &mut Title, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ title.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ #[test]
+ fn without_a_character_you_make_one() {
+ let mut ctx = test_ctx();
+ let mut title = Title::new(&ctx);
+ let keys: Vec<&str> = menu(&ctx, false).iter().map(|&(key, _)| key).collect();
+ assert_eq!(keys, ["n", "g", "q"]);
+ assert!(matches!(press(&mut title, &mut ctx, KeyCode::Enter), Transition::Push(_)));
+ assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('d')), Transition::None));
+ assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('l')), Transition::None));
+ }
+
+ #[test]
+ fn with_one_you_carry_on_where_they_were() {
+ let mut ctx = test_ctx();
+ ctx.slot = Some("someone.json".into());
+ let mut title = Title::new(&ctx);
+ let c = &ctx.character;
+ let carry_on = format!("carry on as {}, of {}", c.name, c.ship_name());
+ assert_eq!(menu(&ctx, true)[0], ("enter", carry_on));
+ let Transition::Open(scenes) = press(&mut title, &mut ctx, KeyCode::Enter) else {
+ panic!("enter carries on");
};
- frame.render_widget(
- Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(),
- footer,
- );
+ assert_eq!(scenes.len(), 1, "flying, as they weren't landed");
+
+ // Whoever comes back to the title may be from another universe.
+ ctx.begin(99, ctx.character.clone());
+ title.on_top(&ctx);
+ assert_eq!(title.universe, 99);
}
}
diff --git a/src/scenes/town.rs b/src/scenes/town.rs
new file mode 100644
index 0000000..abf4e05
--- /dev/null
+++ b/src/scenes/town.rs
@@ -0,0 +1,2509 @@
+//! Walking around a capital city, going into its buildings, and trading in
+//! its shops. The city and the insides of its buildings are generated in
+//! `town`; this moves you about, lets people wander and talk, and runs the
+//! shops' counters.
+//!
+//! Out in the streets you see the city from high up: roofs, with signs on
+//! the shops. Bump a door and the building's inside takes over the screen,
+//! at its own, closer scale. `m` shows the whole city at once.
+//!
+//! People who wander step aside as you walk through them; `t` talks to
+//! someone beside you. People who stay put (keepers, stallholders, guards,
+//! anyone sitting down) are in the way, and bumping them talks to them, or
+//! opens their shop.
+//!
+//! `i` opens the inventory anywhere. What you put down stays where it is
+//! until you leave the city.
+
+use std::collections::{HashMap, HashSet};
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap};
+
+use super::character::Sheet;
+use super::derelict::camera;
+use super::inventory::{self, Beside, Command, Counter, Inventory, Reply, View};
+use super::keys::{self, ACCENT, DIM};
+use super::pause::{Choice, Pause};
+use super::sky::rgb;
+use super::{Scene, Transition};
+use crate::app::{Ctx, Hold, Kept};
+use crate::dungeon::Pos;
+use crate::dungeon::armour::Armor;
+use crate::dungeon::arms::Weapon;
+use crate::items::{self, Item, Stack};
+use crate::names::Culture;
+use crate::seed::{self, Rng};
+use crate::space::survey;
+use crate::space::system::{PlaceKind, Rgb, Terrain};
+use crate::town::{
+ self, Interior, Kind, Land, Person, Pond, Role, Shop, ShopKind, Site, Thing, Tile, Town, words,
+};
+use crate::trade;
+
+const PANEL_WIDTH: u16 = 32;
+const LOG_HEIGHT: u16 = 6;
+/// On average, how many ticks pass between one person's steps.
+const WANDER_TICKS: u32 = 45;
+/// The most steps one run takes.
+const RUN_LIMIT: usize = 80;
+
+const SHIP_ART: [&str; 3] = [" /^\\ ", "<|o|>", " v v "];
+const SIGN: Rgb = (255, 226, 150);
+
+/// How a city looks: how its culture builds, a glyph and colour for each
+/// kind of paving and wall, and the world it stands on.
+pub(crate) struct Theme {
+ world: World,
+ street: (char, Rgb),
+ square: (char, Rgb),
+ wall: (char, Rgb),
+ city_wall: (char, Rgb),
+ /// Roofs come in a few colours, so neighbours stand apart.
+ roofs: [Rgb; 3],
+ floor: (char, Rgb),
+ rug: Rgb,
+ bed: Rgb,
+ centrepiece: (char, Rgb),
+ /// The pipes or scrap a culture leaves lying about, if it does.
+ decor: Option<(char, Rgb)>,
+ stall: (char, Rgb),
+ /// The altar, and the temple's mark on the city map.
+ altar: (char, Rgb),
+ folk: Rgb,
+}
+
+/// How the ground looks, and what grows and stands on it.
+struct World {
+ ground: (char, Rgb),
+ /// Yards, courtyards and parks.
+ garden: (char, Rgb),
+ tree: (char, Rgb),
+ /// Outside the walls: the two sorts of `Tile::Wild`.
+ wild: [(char, Rgb); 2],
+ pond: Pond,
+ /// The sea, and its lighter crests.
+ sea: [Rgb; 2],
+ /// Whether the second sort of wild thing burns: a forge world's vents.
+ vents: bool,
+}
+
+const FROST: Rgb = (226, 236, 246);
+const STRAW: Rgb = (182, 152, 96);
+
+/// `a` mixed with `k` of `b`.
+fn mix((r, g, b): Rgb, (r2, g2, b2): Rgb, k: f64) -> Rgb {
+ let f = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * k).round() as u8;
+ (f(r, r2), f(g, g2), f(b, b2))
+}
+
+/// How a city's world looks: its kind of ground, frosted or parched by the
+/// weather today, and tinted a little its own way.
+fn world(t: &Town) -> World {
+ let land = t.land;
+ let mut w = match land.terrain {
+ Terrain::Desert => World {
+ ground: ('·', (204, 170, 112)),
+ garden: (',', (166, 156, 92)),
+ tree: ('♣', (128, 162, 78)),
+ wild: [('ψ', (118, 156, 86)), ('●', (170, 128, 88))],
+ pond: Pond::Water,
+ sea: [(0, 0, 0); 2],
+ vents: false,
+ },
+ Terrain::Ice => World {
+ ground: ('·', (214, 228, 242)),
+ garden: ('"', (150, 204, 214)),
+ tree: ('♠', (130, 196, 222)),
+ wild: [('▲', (196, 226, 248)), ('●', (140, 150, 168))],
+ pond: Pond::Ice,
+ sea: [(0, 0, 0); 2],
+ vents: false,
+ },
+ Terrain::Ocean => World {
+ ground: ('·', (104, 100, 112)),
+ garden: ('"', (86, 168, 116)),
+ tree: ('♣', (60, 150, 100)),
+ wild: [('●', (82, 80, 92)), ('§', (96, 136, 84))],
+ pond: Pond::Water,
+ sea: [(40, 92, 168), (110, 170, 220)],
+ vents: false,
+ },
+ Terrain::Jungle => World {
+ ground: ('"', (58, 120, 62)),
+ garden: ('"', (74, 160, 72)),
+ tree: ('♣', (44, 140, 56)),
+ wild: [('♣', (38, 112, 48)), ('♠', (60, 132, 72))],
+ pond: Pond::Water,
+ sea: [(0, 0, 0); 2],
+ vents: false,
+ },
+ Terrain::Barren | Terrain::Clouds => World {
+ ground: ('·', (134, 128, 122)),
+ garden: (';', (176, 102, 70)),
+ tree: ('♦', (156, 124, 214)),
+ wild: [('▲', (142, 134, 126)), ('♦', (112, 98, 152))],
+ pond: Pond::Dry,
+ sea: [(0, 0, 0); 2],
+ vents: false,
+ },
+ Terrain::Forge => World {
+ ground: ('·', (92, 78, 76)),
+ garden: (',', (164, 118, 64)),
+ tree: ('τ', (206, 128, 92)),
+ wild: [('▲', (84, 74, 74)), ('^', (255, 130, 50))],
+ pond: Pond::Lava,
+ sea: [(0, 0, 0); 2],
+ vents: true,
+ },
+ };
+ w.pond = land.pond();
+ let tint = t.tint();
+ // How much frost and drought show on each: living things wither, the
+ // sea only scums with ice.
+ let paint = |c: Rgb, (frost, parch): (f64, f64)| {
+ let c = mix(c, FROST, frost * land.frost);
+ let c = mix(c, STRAW, parch * land.drought);
+ let k = |v: u8, i: usize| (f64::from(v) * tint[i]).round().clamp(0.0, 255.0) as u8;
+ (k(c.0, 0), k(c.1, 1), k(c.2, 2))
+ };
+ let (living, lying) = ((0.7, 0.6), (0.7, 0.15));
+ let ground = if land.terrain == Terrain::Jungle { living } else { lying };
+ w.ground.1 = paint(w.ground.1, ground);
+ w.garden.1 = paint(w.garden.1, living);
+ w.tree.1 = paint(w.tree.1, living);
+ for wild in &mut w.wild {
+ wild.1 = paint(wild.1, ground);
+ }
+ w.sea = w.sea.map(|c| paint(c, (0.3, 0.0)));
+ w
+}
+
+pub(crate) fn theme(t: &Town) -> Theme {
+ let world = world(t);
+ match t.culture {
+ Culture::Qaari => Theme {
+ world,
+ street: ('.', (170, 140, 96)),
+ square: (':', (196, 166, 116)),
+ wall: ('#', (214, 176, 124)),
+ city_wall: ('█', (176, 136, 88)),
+ roofs: [(170, 128, 82), (206, 190, 160), (190, 120, 70)],
+ floor: ('.', (130, 110, 88)),
+ rug: (170, 70, 60),
+ bed: (200, 150, 90),
+ centrepiece: ('≈', (96, 164, 226)),
+ decor: None,
+ stall: ('▀', (220, 120, 80)),
+ altar: ('O', (96, 164, 226)),
+ folk: (226, 196, 150),
+ },
+ Culture::Cant => Theme {
+ world,
+ street: ('·', (132, 138, 152)),
+ square: ('+', (150, 152, 164)),
+ wall: ('#', (176, 112, 90)),
+ city_wall: ('▓', (120, 90, 80)),
+ roofs: [(120, 88, 76), (100, 104, 116), (140, 96, 64)],
+ floor: ('.', (120, 120, 132)),
+ rug: (120, 60, 50),
+ bed: (150, 150, 160),
+ centrepiece: ('Ω', (255, 140, 60)),
+ decor: Some(('‡', (150, 152, 172))),
+ stall: ('▀', (170, 120, 200)),
+ altar: ('Ω', (255, 140, 60)),
+ folk: (200, 160, 140),
+ },
+ Culture::Houses => Theme {
+ world,
+ street: ('·', (200, 196, 210)),
+ square: (':', (222, 218, 232)),
+ wall: ('#', (226, 222, 236)),
+ city_wall: ('█', (190, 186, 202)),
+ roofs: [(160, 96, 126), (176, 90, 70), (90, 110, 140)],
+ floor: ('.', (184, 172, 160)),
+ rug: (150, 50, 80),
+ bed: (190, 60, 90),
+ centrepiece: ('≈', (110, 170, 240)),
+ decor: None,
+ stall: ('▀', (200, 120, 200)),
+ altar: ('†', (240, 210, 120)),
+ folk: (220, 200, 240),
+ },
+ Culture::Drift => Theme {
+ world,
+ street: ('-', (110, 150, 150)),
+ square: (':', (130, 190, 184)),
+ wall: ('=', (124, 212, 200)),
+ city_wall: ('▓', (80, 120, 130)),
+ roofs: [(60, 110, 120), (120, 120, 110), (170, 110, 70)],
+ floor: ('.', (120, 140, 140)),
+ rug: (60, 120, 150),
+ bed: (90, 170, 170),
+ centrepiece: ('¥', (230, 230, 160)),
+ decor: Some(('%', (150, 120, 90))),
+ stall: ('▀', (230, 150, 90)),
+ altar: ('¥', (230, 230, 160)),
+ folk: (160, 220, 214),
+ },
+ }
+}
+
+/// How a culture's shopkeepers greet you.
+fn salutation(culture: Culture) -> &'static str {
+ match culture {
+ Culture::Qaari => "Water and shade, traveller.",
+ Culture::Cant => "Be welcome, unit. State your need.",
+ Culture::Houses => "Your grace honours my house.",
+ Culture::Drift => "Hey, spacer.",
+ }
+}
+
+fn pitch(kind: ShopKind) -> &'static str {
+ match kind {
+ ShopKind::Broker => "I buy ore by the ton. Show me what you've dug up.",
+ ShopKind::Doctor => "Medpatches, fresh. They'll keep you breathing aboard those wrecks.",
+ ShopKind::Scholar => {
+ "Bring me the strange things you find. I'll tell you what they are, \
+ and buy what's worth buying."
+ }
+ ShopKind::Tavern => "Buy a round and the room will talk. Rooms always talk.",
+ ShopKind::Shipyard => {
+ "Fuel for the jump, racks for more ore, a finer lens for your laser. \
+ Or a bigger tank, if you mean to go far."
+ }
+ ShopKind::Armoury => {
+ "Arms and armour, bought, sold and mended. Show me what you've found, or try the rack."
+ }
+ }
+}
+
+fn shelf(kind: ShopKind) -> (char, Rgb) {
+ match kind {
+ ShopKind::Broker => ('%', (170, 140, 110)),
+ ShopKind::Doctor => ('!', (240, 96, 96)),
+ ShopKind::Scholar => ('≡', (206, 186, 140)),
+ ShopKind::Tavern => ('o', (170, 116, 72)),
+ ShopKind::Shipyard => ('¤', (150, 172, 196)),
+ ShopKind::Armoury => (')', (186, 190, 204)),
+ }
+}
+
+/// What a door says when it won't open.
+fn barred(kind: Kind) -> &'static str {
+ match kind {
+ Kind::Manor => "A servant looks out through a grille at you, and shuts it again.",
+ Kind::Warehouse => "Locked, and chained for good measure.",
+ Kind::Tenement => "The street door's locked. A face at a window watches you.",
+ Kind::Workshop => "A slate on the door: closed for a commission.",
+ _ => "The door is barred. Someone's home, but not for you.",
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Deal {
+ SellOre,
+ /// Sell the salvage kept here, all of it.
+ SellSalvage(Kept),
+ SellAllSalvage,
+ BuyMedpatch,
+ /// Have the artifact kept here identified.
+ Identify(Kept),
+ SellArtifact(Kept),
+ BuyRound,
+ BuyPack,
+ BuyRacks,
+ BuyLens,
+ BuyFuel,
+ BuyTank,
+}
+
+struct Offer {
+ text: String,
+ deal: Deal,
+}
+
+struct Trading {
+ shop: usize,
+ choice: usize,
+ /// What the keeper said to your last request.
+ reply: Option<String>,
+}
+
+pub struct TownScene {
+ town: Town,
+ /// The building you're in, if you're indoors.
+ inside: Option<Interior>,
+ place: usize,
+ /// "Qeth", the world the town is on.
+ world: String,
+ player: Pos,
+ /// The way you last stepped, which is who `t` talks to first.
+ facing: (i32, i32),
+ log: Vec<String>,
+ /// How many things have been said, ever: the log itself stops growing.
+ said: usize,
+ trading: Option<Trading>,
+ /// Looking at the whole city.
+ map: bool,
+ /// Buildings you've been into.
+ visited: HashSet<usize>,
+ rumours: Vec<String>,
+ heard: usize,
+ rng: Rng,
+ /// Open over the city when you're looking through what you carry.
+ inventory: Option<Inventory>,
+ /// The armourer whose rack is beside you in the inventory, when you're
+ /// trading with one.
+ shopping: Option<usize>,
+ /// What you've put down, and where: in the streets, or in a building.
+ dropped: Vec<(Spot, Stack)>,
+ /// The menu `esc` opens.
+ pause: Option<Pause>,
+}
+
+/// Somewhere in the city: the building it's in, if it's indoors, and where
+/// in it.
+type Spot = (Option<usize>, Pos);
+
+impl TownScene {
+ /// Walks out of your ship onto the landing field outside the capital of
+ /// `place`, a planet or moon where people live.
+ pub fn new(ctx: &Ctx, place: usize) -> TownScene {
+ let s = &ctx.system;
+ let (world, people, description) = match s.places[place].kind {
+ PlaceKind::Planet(p) => {
+ let planet = &s.planets[p];
+ (&planet.name, planet.people, &planet.description)
+ }
+ PlaceKind::Moon(p, m) => {
+ let moon = &s.planets[p].moons[m];
+ (&moon.name, moon.people, &moon.description)
+ }
+ PlaceKind::Belt | PlaceKind::Station(_) => (&s.places[place].name, None, &Vec::new()),
+ };
+ let culture = people.unwrap_or(s.culture);
+ let size = town::size_for(survey::survey(s, place, ctx.day).population);
+ let land = Land::of(s, place, ctx.day).unwrap_or(Land::new(Terrain::Barren, None));
+ let town = town::generate(town::seed_for(s.seed, place), culture, size, land);
+ let mut log = vec![format!(
+ "You walk down the ramp onto the landing field outside {}, the capital of {world}.",
+ town.name
+ )];
+ log.extend(description.iter().cloned());
+ log.push("The gate is east. Bump a door to go in; m shows the whole city.".into());
+ TownScene {
+ player: town.start,
+ facing: (1, 0),
+ place,
+ world: world.clone(),
+ said: log.len(),
+ log,
+ trading: None,
+ map: false,
+ visited: HashSet::new(),
+ rumours: trade::rumours(
+ s,
+ place,
+ ctx.day,
+ &ctx.stripped,
+ ctx.ticks,
+ trade::lane_rumours(&ctx.galaxy, ctx.star),
+ ),
+ heard: 0,
+ rng: seed::rng(seed::derive(s.seed, "townsfolk", ctx.ticks)),
+ inside: None,
+ town,
+ inventory: None,
+ shopping: None,
+ dropped: Vec::new(),
+ pause: None,
+ }
+ }
+
+ /// Where you're standing.
+ fn here(&self) -> Spot {
+ (self.inside.as_ref().map(|i| i.building), self.player)
+ }
+
+ fn floor(&self) -> Beside {
+ Beside::Floor(if self.inside.is_some() { "on the floor" } else { "on the ground" })
+ }
+
+ /// `g`: picks up what's where you stand, or lists it if there's more
+ /// than one thing.
+ fn pick_up(&mut self, ctx: &mut Ctx) {
+ match &inventory::lying(&self.dropped, self.here())[..] {
+ [] => self.say("There's nothing here to pick up.".into()),
+ [stack] => {
+ let text = self.carry_out(inventory::Command::Fetch(0, stack.count), ctx);
+ self.say(text);
+ }
+ _ => self.inventory = Some(Inventory::beside()),
+ }
+ }
+
+ fn carry_out(&mut self, command: inventory::Command, ctx: &mut Ctx) -> String {
+ let (here, beside) = (self.here(), self.floor());
+ let mut floor = inventory::gather(&mut self.dropped, here);
+ let text =
+ inventory::carry_out(command, &mut ctx.hold.gear, None, &mut floor, Some(beside));
+ inventory::scatter(&mut self.dropped, here, floor);
+ text
+ }
+
+ /// Steps up to a shop's counter. An armourer's rack opens beside what
+ /// you carry, to buy from and sell to; every other shop has a counter.
+ fn open_shop(&mut self, shop: usize) {
+ let s = &self.town.shops[shop];
+ if s.kind == ShopKind::Armoury {
+ let mut inv = Inventory::beside();
+ inv.note = Some(format!(
+ "{}: “{} {}”",
+ s.keeper,
+ salutation(self.town.culture),
+ pitch(s.kind)
+ ));
+ self.inventory = Some(inv);
+ self.shopping = Some(shop);
+ } else {
+ self.trading = Some(Trading { shop, choice: 0, reply: None });
+ }
+ }
+
+ /// What's on this city's armourer's rack this fortnight, as you left
+ /// it: the weapons, then the armour.
+ fn rack(&self, ctx: &Ctx) -> Vec<Stack> {
+ let fortnight = trade::fortnight(ctx.day);
+ let kept = ctx.racks.iter().find(|r| {
+ r.system == ctx.system.seed && r.place == self.place && r.fortnight == fortnight
+ });
+ let (weapons, armour) = match kept {
+ Some(r) => (r.weapons.clone(), r.armour.clone()),
+ None => (
+ trade::stock(&ctx.system, self.place, self.town.culture, fortnight),
+ trade::stock_armour(&ctx.system, self.place, self.town.culture, fortnight),
+ ),
+ };
+ let weapons = weapons.into_iter().map(|w| Stack::one(Item::Weapon(w)));
+ weapons.chain(armour.into_iter().map(|a| Stack::one(Item::Armor(a)))).collect()
+ }
+
+ fn inventory_key(&mut self, key: KeyEvent, ctx: &mut Ctx) {
+ let floor = inventory::lying(&self.dropped, self.here());
+ let rack = if self.shopping.is_some() { self.rack(ctx) } else { Vec::new() };
+ let view = match self.shopping {
+ Some(_) => shop_view(ctx, self.place, &rack),
+ None => view(ctx, self.floor(), &floor),
+ };
+ let Some(inv) = &mut self.inventory else { return };
+ match inv.key(key, &view) {
+ Reply::Stay => {}
+ Reply::Close => {
+ self.inventory = None;
+ self.shopping = None;
+ }
+ Reply::Sort(sort) => ctx.inventory_sort = sort,
+ Reply::Do(command) => {
+ let text = match command {
+ Command::Buy(i) => self.buy(i, ctx),
+ Command::Sell(spot) => self.sell(Some(spot), None, ctx),
+ Command::SellAboard(i) => self.sell(None, Some(i), ctx),
+ Command::Mend(spot) => self.mend(Some(spot), None, ctx),
+ Command::MendAboard(i) => self.mend(None, Some(i), ctx),
+ Command::MendAll => self.mend(None, None, ctx),
+ _ => self.carry_out(command, ctx),
+ };
+ if let Some(inv) = &mut self.inventory {
+ inv.note = Some(text.clone());
+ }
+ self.say(text);
+ }
+ }
+ }
+
+ /// Buys the `i`th thing on the armourer's rack (as `rack` lists it), if
+ /// you can pay.
+ fn buy(&mut self, i: usize, ctx: &mut Ctx) -> String {
+ let terms = trade::armoury_terms(&ctx.system, self.place);
+ let rack = trade::rack(&mut ctx.racks, &ctx.system, self.place, self.town.culture, ctx.day);
+ let armour = i.checked_sub(rack.weapons.len());
+ let item = match armour {
+ None => Item::Weapon(rack.weapons[i].clone()),
+ Some(j) => match rack.armour.get(j) {
+ Some(a) => Item::Armor(a.clone()),
+ None => return "That's gone already.".into(),
+ },
+ };
+ let price = terms.asking(&item).unwrap_or(0);
+ let h = &mut ctx.hold;
+ if h.scrip < price {
+ return format!("That's {price} ¤, and you've {} ¤. Come back richer.", h.scrip);
+ }
+ h.scrip -= price;
+ match armour {
+ None => drop(rack.weapons.remove(i)),
+ Some(j) => drop(rack.armour.remove(j)),
+ }
+ let said = match &item {
+ Item::Weapon(w) if w.kit => format!("You buy back {} for {price} ¤.", w.name()),
+ Item::Weapon(w) => format!("{} is yours, for {price} ¤.", capitalise(&the(w))),
+ Item::Armor(a) => format!("{} {} yours, for {price} ¤.", capitalise(&a.the()), a.is()),
+ _ => String::new(),
+ };
+ if h.deliver(Stack::one(item)) {
+ said
+ } else {
+ format!("{said} Your pack's full, so it'll be sent to your ship.")
+ }
+ }
+
+ /// Sells a weapon or a piece of armour of yours to the armourer: one
+ /// you carry or wear (`carried`) or one in your ship's lockers
+ /// (`aboard`). It goes on the rack, to be bought back until the rack's
+ /// restocked.
+ fn sell(
+ &mut self,
+ carried: Option<items::Spot>,
+ aboard: Option<usize>,
+ ctx: &mut Ctx,
+ ) -> String {
+ let terms = trade::armoury_terms(&ctx.system, self.place);
+ let h = &mut ctx.hold;
+ let dealt = |s: Option<Stack>| s.filter(|s| terms.offer(&s.item).is_some());
+ let stack = match (carried, aboard) {
+ (Some(spot), _) => {
+ if dealt(h.gear.get(spot)).is_none() {
+ return "I only deal in arms and armour.".into();
+ }
+ match h.gear.remove(spot, 1) {
+ Ok(stack) => stack,
+ Err(why) => return why,
+ }
+ }
+ (None, Some(i)) => {
+ if dealt(h.lockers.get(i).cloned()).is_none() {
+ return "You've nothing like that aboard.".into();
+ }
+ h.take_kept(Kept::Lockers(i), 1)
+ }
+ (None, None) => return String::new(),
+ };
+ let price = terms.offer(&stack.item).unwrap_or(0);
+ let what = match &stack.item {
+ Item::Weapon(w) => the(w),
+ Item::Armor(a) => a.the(),
+ _ => return String::new(),
+ };
+ let paid = h.earn(price);
+ let said =
+ format!("{price} ¤ for {what}. It'll find a new owner.{}", trade::debt_note(paid, h));
+ let rack = trade::rack(&mut ctx.racks, &ctx.system, self.place, self.town.culture, ctx.day);
+ match stack.item {
+ Item::Weapon(w) => rack.weapons.push(w),
+ Item::Armor(a) => rack.armour.push(a),
+ _ => {}
+ }
+ said
+ }
+
+ /// Has the armourer mend a piece of your armour whole: one you carry or
+ /// wear (`carried`), one in your ship's lockers (`aboard`), or with
+ /// neither, every piece that needs it, all at once.
+ fn mend(
+ &mut self,
+ carried: Option<items::Spot>,
+ aboard: Option<usize>,
+ ctx: &mut Ctx,
+ ) -> String {
+ let terms = trade::armoury_terms(&ctx.system, self.place);
+ let h = &mut ctx.hold;
+ let mut pieces: Vec<&mut Armor> = match (carried, aboard) {
+ (Some(items::Spot::Worn(slot)), _) => h.gear.worn.get_mut(&slot).into_iter().collect(),
+ (Some(items::Spot::Pack(i)), _) => {
+ armour_in(h.gear.pack.get_mut(i)).into_iter().collect()
+ }
+ (Some(_), _) => Vec::new(),
+ (None, Some(i)) => armour_in(h.lockers.get_mut(i)).into_iter().collect(),
+ (None, None) => {
+ let aboard = h.lockers.iter_mut().filter_map(|s| armour_in(Some(s)));
+ h.gear.armour_mut().chain(aboard).filter(|a| terms.mending(a) > 0).collect()
+ }
+ };
+ let all = carried.is_none() && aboard.is_none();
+ let cost: u32 = pieces.iter().map(|a| terms.mending(a)).sum();
+ match pieces.as_slice() {
+ [] if all => return "Nothing of yours needs mending.".into(),
+ [] => return "That's not armour.".into(),
+ [a] if cost == 0 => {
+ return format!("{} {} whole already.", capitalise(&a.yours()), a.is());
+ }
+ _ => {}
+ }
+ if h.scrip < cost {
+ let what = if pieces.len() > 1 { format!("all {}", pieces.len()) } else { "it".into() };
+ return format!("That's {cost} ¤ to mend {what}, and you've {} ¤.", h.scrip);
+ }
+ h.scrip -= cost;
+ for a in &mut pieces {
+ a.repair();
+ }
+ match pieces.as_slice() {
+ [a] => format!("{} {} whole again, for {cost} ¤.", capitalise(&a.yours()), a.is()),
+ _ => format!("All {} pieces are whole again, for {cost} ¤.", pieces.len()),
+ }
+ }
+
+ fn say(&mut self, text: String) {
+ self.log.push(text);
+ self.said += 1;
+ if self.log.len() > 50 {
+ self.log.remove(0);
+ }
+ }
+
+ /// Where you are: the streets, or the inside of a building.
+ fn site(&self) -> &Site {
+ self.inside.as_ref().map_or(&self.town.site, |i| &i.site)
+ }
+
+ fn site_mut(&mut self) -> &mut Site {
+ match &mut self.inside {
+ Some(inside) => &mut inside.site,
+ None => &mut self.town.site,
+ }
+ }
+
+ /// One step, and what's lying where you end up.
+ fn step(&mut self, dx: i32, dy: i32) -> Transition {
+ let before = self.here();
+ let transition = self.stride(dx, dy);
+ if self.here() != before
+ && let Some(text) = inventory::underfoot(&inventory::lying(&self.dropped, self.here()))
+ {
+ self.say(text);
+ }
+ transition
+ }
+
+ fn stride(&mut self, dx: i32, dy: i32) -> Transition {
+ self.facing = (dx, dy);
+ let from = self.player;
+ let to = from.offset(dx, dy);
+ if let Some(who) = self.site().person_at(to) {
+ let person = &self.site().people[who];
+ if let Role::Keeper(shop) = person.role {
+ self.open_shop(shop);
+ return Transition::None;
+ }
+ if person.stays {
+ let text = self.talk(person);
+ self.say(text);
+ return Transition::None;
+ }
+ // Someone walking about: they step aside for you, onto where
+ // you were, if they can.
+ if self.site().tile(to).walkable() {
+ self.player = to;
+ self.site_mut().make_way(who, from);
+ }
+ return Transition::None;
+ }
+ if self.inside.is_some() { self.step_inside(to) } else { self.step_outside(to) }
+ }
+
+ fn step_outside(&mut self, to: Pos) -> Transition {
+ let t = &self.town;
+ if !t.site.contains(to) {
+ if t.tile(self.player) == Tile::Street {
+ self.say(words::beyond(t.land.terrain).into());
+ }
+ return Transition::None;
+ }
+ match t.tile(to) {
+ Tile::Ship => return Transition::Pop,
+ Tile::Door { open } => {
+ let Some(b) = t.door_at(to) else { return Transition::None };
+ if open {
+ self.go_in(b);
+ } else {
+ let text = barred(t.buildings[b].kind);
+ self.say(text.into());
+ }
+ }
+ tile if tile.walkable() => self.player = to,
+ tile => {
+ let text = match tile {
+ Tile::Wild => Some(words::wild_about(t.land.terrain, t.wild_sort(to))),
+ _ => words::tile_about(tile, t.culture, t.land),
+ };
+ if let Some(text) = text {
+ self.say(text.into());
+ }
+ }
+ }
+ Transition::None
+ }
+
+ fn step_inside(&mut self, to: Pos) -> Transition {
+ let Some(inside) = &self.inside else { return Transition::None };
+ if let Some(shop) = inside.shop_at(to) {
+ self.open_shop(shop);
+ return Transition::None;
+ }
+ match inside.site.tile(to) {
+ Tile::Exit => self.go_out(),
+ tile if tile.walkable() => self.player = to,
+ Tile::Furniture(thing) => {
+ self.say(words::thing_about(thing, inside.culture).into());
+ }
+ Tile::Shelf => self.say("The keeper's wares, out of reach behind the counter.".into()),
+ _ => {}
+ }
+ Transition::None
+ }
+
+ fn go_in(&mut self, b: usize) {
+ let inside = self.town.enter(b);
+ self.player = inside.start;
+ self.visited.insert(b);
+ let building = &self.town.buildings[b];
+ let mut text = format!("You step into {}.", building.name);
+ let culture = self.town.culture;
+ let named = |role: Role| {
+ inside.site.people.iter().find(|p| p.role == role).and_then(|p| p.name.clone())
+ };
+ match building.kind {
+ Kind::Shop(ShopKind::Tavern) => {
+ let keeper = &self.town.shops[building.shop.unwrap_or(0)].keeper;
+ text += &format!(" It's warm and loud. {keeper} is behind the bar.");
+ }
+ Kind::Shop(_) => {
+ let keeper = &self.town.shops[building.shop.unwrap_or(0)].keeper;
+ text += &format!(" {keeper} is behind the counter.");
+ }
+ Kind::Palace => {
+ if let Some(name) = named(Role::Ruler) {
+ let title = words::ruler_title(culture);
+ text += &format!(" At the far end, {name}, {title}, sits on the throne.");
+ }
+ }
+ Kind::Temple | Kind::Shrine => text += " It is hushed, and smells of old smoke.",
+ Kind::House | Kind::Tenement | Kind::Manor => {
+ let home = inside.site.people.len();
+ text += match home {
+ 0 => " Nobody's home.",
+ 1 => " Someone looks up as you come in.",
+ _ => " The household looks up as you come in.",
+ };
+ }
+ _ => {}
+ }
+ self.inside = Some(inside);
+ self.say(text);
+ }
+
+ fn go_out(&mut self) {
+ let Some(inside) = self.inside.take() else { return };
+ let b = &self.town.buildings[inside.building];
+ self.player = b.front();
+ self.facing = b.facing.step();
+ // Someone may have wandered onto the doorstep.
+ if let Some(who) = self.town.site.person_at(self.player) {
+ let away = self.player.offset(self.facing.0, self.facing.1);
+ self.town.site.make_way(who, away);
+ }
+ let quarter = self.town.quarter_at(self.player).map_or("the street", |q| q.name.as_str());
+ let text = format!("You step back out into {quarter}.");
+ self.say(text);
+ }
+
+ /// What `person` says, with who they are.
+ fn talk(&self, person: &Person) -> String {
+ let culture = self.town.culture;
+ match (&person.name, person.role) {
+ (_, Role::Stallholder) => format!("A stallholder calls out: “{}”", person.says),
+ (Some(name), Role::Ruler) => {
+ format!("{name}, {}: “{}”", words::ruler_title(culture), person.says)
+ }
+ (Some(name), _) => format!("{name}: “{}”", person.says),
+ (None, role) => format!("{}: “{}”", words::role_name(role, culture), person.says),
+ }
+ }
+
+ /// Talks to someone beside you: whoever you're facing, or else anyone.
+ fn talk_nearby(&mut self) {
+ let site = self.site();
+ let ahead = self.player.offset(self.facing.0, self.facing.1);
+ let who = site
+ .person_at(ahead)
+ .or_else(|| self.player.neighbours().find_map(|p| site.person_at(p)));
+ let Some(who) = who else {
+ self.say("There's nobody close enough to talk to.".into());
+ return;
+ };
+ let person = &self.site().people[who];
+ let (role, pos) = (person.role, person.pos);
+ if let Role::Keeper(shop) = role {
+ self.open_shop(shop);
+ return;
+ }
+ let directions =
+ if self.inside.is_none() && role == Role::Passerby && self.rng.random_bool(0.4) {
+ self.directions(pos)
+ } else {
+ None
+ };
+ let text = directions.unwrap_or_else(|| self.talk(&self.site().people[who]));
+ self.say(text);
+ }
+
+ /// A passer-by tells you the way to a shop, one you haven't been into
+ /// if there is one.
+ fn directions(&mut self, from: Pos) -> Option<String> {
+ let t = &self.town;
+ let unseen: Vec<ShopKind> = ShopKind::ALL
+ .into_iter()
+ .filter(|&k| {
+ t.nearest_shop(k, from).is_some_and(|s| !self.visited.contains(&s.building))
+ })
+ .collect();
+ let kind = *unseen.choose(&mut self.rng).or_else(|| ShopKind::ALL.choose(&mut self.rng))?;
+ let shop = t.nearest_shop(kind, from)?;
+ let to = t.buildings[shop.building].front();
+ let (dx, dy) = (to.x - from.x, to.y - from.y);
+ let way = compass(dx, dy);
+ let here = t.quarter_at(from).map(|q| q.name.as_str());
+ let there = t.quarter_at(to).map_or("the city", |q| q.name.as_str());
+ let far = f64::from(town::distance2(to, from)).sqrt();
+ let text = if far < 14.0 {
+ format!("A passer-by: “{}? It's just {way} of here.”", capitalise(&shop.name))
+ } else if here == Some(there) {
+ format!(
+ "A passer-by: “Looking for {}? Try {}, {way} of here.”",
+ kind.trade(),
+ shop.name
+ )
+ } else {
+ format!(
+ "A passer-by: “Looking for {}? Try {}, {way} of here, in {there}.”",
+ kind.trade(),
+ shop.name
+ )
+ };
+ Some(text)
+ }
+
+ /// Steps until something changes: a turning, a door, someone in the
+ /// way, something said.
+ fn run(&mut self, dx: i32, dy: i32) -> Transition {
+ let mut around = self.around(dx, dy);
+ for _ in 0..RUN_LIMIT {
+ let (before, indoors, said) = (self.player, self.inside.is_some(), self.said);
+ let transition = self.step(dx, dy);
+ if !matches!(transition, Transition::None) {
+ return transition;
+ }
+ let stopped = self.player == before
+ || self.inside.is_some() != indoors
+ || self.trading.is_some()
+ || self.said != said;
+ let now = self.around(dx, dy);
+ if stopped || now != around {
+ break;
+ }
+ around = now;
+ }
+ Transition::None
+ }
+
+ /// Which cells beside you are open, as you run: one and two to either
+ /// side for a straight run, all eight round you for a diagonal one.
+ fn around(&self, dx: i32, dy: i32) -> u8 {
+ let site = self.site();
+ let open = |p: Pos| site.tile(p).walkable() || matches!(site.tile(p), Tile::Door { .. });
+ let cells: Vec<Pos> = if dx != 0 && dy != 0 {
+ self.player.neighbours().collect()
+ } else {
+ let (sx, sy) = (-dy, dx);
+ [1, 2]
+ .into_iter()
+ .flat_map(|k| {
+ [self.player.offset(sx * k, sy * k), self.player.offset(-sx * k, -sy * k)]
+ })
+ .collect()
+ };
+ cells.into_iter().enumerate().fold(0, |bits, (i, p)| bits | (u8::from(open(p)) << i))
+ }
+
+ /// Esc: out to the street if you're indoors, or back to your ship.
+ fn leave(&mut self) -> Transition {
+ if self.inside.is_some() {
+ self.go_out();
+ Transition::None
+ } else {
+ Transition::Pop
+ }
+ }
+
+ fn offers(&self, shop: &Shop, ctx: &Ctx) -> Vec<Offer> {
+ let s = &ctx.system;
+ let h = &ctx.hold;
+ let mut offers = Vec::new();
+ let mut offer = |text: String, deal| offers.push(Offer { text, deal });
+ match shop.kind {
+ ShopKind::Broker => {
+ let tons = h.ore_tons();
+ if tons > 0 {
+ let prices = trade::ore_prices(s, self.place);
+ let worth = ore_worth(h, &prices);
+ offer(format!("Sell all your ore, {tons} t, for {worth} ¤"), Deal::SellOre);
+ }
+ let salvage = trade::salvage_kept(h, s, self.place);
+ for &(at, worth) in &salvage {
+ let Some(stack) = h.kept().find(|&(k, _)| k == at).map(|(_, s)| s) else {
+ continue;
+ };
+ let text = format!("Sell {}: {worth} ¤{}", stack.name(), at.note());
+ offer(text, Deal::SellSalvage(at));
+ }
+ if salvage.len() > 1 {
+ let worth: u32 = salvage.iter().map(|(_, w)| w).sum();
+ offer(format!("Sell all your salvage for {worth} ¤"), Deal::SellAllSalvage);
+ }
+ }
+ ShopKind::Doctor => {
+ let price = trade::medpatch_price(s, self.place);
+ offer(format!("Buy a medpatch: {price} ¤"), Deal::BuyMedpatch);
+ }
+ ShopKind::Scholar => {
+ let fee = trade::identify_fee(s, self.place);
+ for (at, stack) in h.kept() {
+ let Item::Artifact(a) = &stack.item else { continue };
+ let note = at.note();
+ if a.identified {
+ let pay = trade::artifact_offer(s, self.place, a);
+ offer(
+ format!("Sell {}: {pay} ¤{note}", a.true_name),
+ Deal::SellArtifact(at),
+ );
+ } else {
+ offer(
+ format!("Have {} studied: {fee} ¤{note}", a.unidentified),
+ Deal::Identify(at),
+ );
+ }
+ }
+ }
+ ShopKind::Tavern => {
+ offer(format!("Buy a round, and listen: {} ¤", trade::ROUND_PRICE), Deal::BuyRound);
+ }
+ // The armourer's rack opens in the inventory instead (`open_shop`).
+ ShopKind::Armoury => {}
+ ShopKind::Shipyard => {
+ let room = h.fuel_capacity.saturating_sub(h.fuel);
+ if room > 0 {
+ let price = trade::fuel_price(s, self.place);
+ let s = if room == 1 { "" } else { "s" };
+ offer(
+ format!("Fill your tank, {room} jump{s} of fuel: {} ¤", room * price),
+ Deal::BuyFuel,
+ );
+ }
+ if let Some(cost) = trade::rack_cost(h) {
+ let more = h.ore_capacity + trade::RACK_TONS;
+ offer(
+ format!("Cargo racks, to hold {more} t of ore: {cost} ¤"),
+ Deal::BuyRacks,
+ );
+ }
+ if let Some(cost) = trade::lens_cost(ctx.lenses) {
+ offer(format!("A finer laser lens, to cut faster: {cost} ¤"), Deal::BuyLens);
+ }
+ if let Some(cost) = trade::tank_cost(h) {
+ let more = h.fuel_capacity + trade::TANK_JUMPS;
+ offer(format!("An extra fuel tank, for {more} jumps: {cost} ¤"), Deal::BuyTank);
+ }
+ if let Some((name, more, cost)) = items::next_pack(h.gear.capacity) {
+ let kg = items::kg(h.gear.capacity + more);
+ offer(format!("A {name}, to carry {kg} kg: {cost} ¤"), Deal::BuyPack);
+ }
+ }
+ }
+ offers
+ }
+
+ /// Does the deal if you can, and says how it went.
+ fn deal(&mut self, deal: Deal, ctx: &mut Ctx) -> String {
+ let s = &ctx.system;
+ let h = &mut ctx.hold;
+ let place = self.place;
+ let pay = |h: &mut Hold, cost: u32| {
+ let ok = h.scrip >= cost;
+ if ok {
+ h.scrip -= cost;
+ }
+ ok
+ };
+ let broke = || "You can't afford that.".to_string();
+ match deal {
+ Deal::SellOre => {
+ let debt = h.debt;
+ match trade::sell_ore(h, &trade::ore_prices(s, place)) {
+ Some((tons, earned)) => format!(
+ "Sold {tons} t of ore for {earned} ¤. A pleasure.{}",
+ trade::debt_note(debt - h.debt, h)
+ ),
+ None => "You've no ore to sell.".into(),
+ }
+ }
+ Deal::SellSalvage(at) => {
+ let debt = h.debt;
+ match trade::sell_salvage(h, s, place, at) {
+ Some((what, earned)) => format!(
+ "{earned} ¤ for {what}. Good scrap.{}",
+ trade::debt_note(debt - h.debt, h)
+ ),
+ None => "You've no salvage to sell.".into(),
+ }
+ }
+ Deal::SellAllSalvage => {
+ let debt = h.debt;
+ match trade::sell_all_salvage(h, s, place) {
+ Some((pieces, earned)) => format!(
+ "{earned} ¤ for the lot, {pieces} pieces. Good scrap.{}",
+ trade::debt_note(debt - h.debt, h)
+ ),
+ None => "You've no salvage to sell.".into(),
+ }
+ }
+ Deal::BuyMedpatch => {
+ if h.medpatches() >= trade::MEDPATCH_LIMIT {
+ format!(
+ "You've {} already. I won't sell you more than {}.",
+ h.medpatches(),
+ trade::MEDPATCH_LIMIT
+ )
+ } else if pay(h, trade::medpatch_price(s, place)) {
+ if h.deliver(Stack::one(Item::Medpatch)) {
+ format!("Here. Don't waste it. (You have {}.)", h.medpatches())
+ } else {
+ "Here. Your pack's full, so I'll have it sent to your ship.".into()
+ }
+ } else {
+ broke()
+ }
+ }
+ Deal::Identify(at) => {
+ if !matches!(h.kept_mut(at), Some(Stack { item: Item::Artifact(_), .. })) {
+ "You've nothing like that to show me.".into()
+ } else if pay(h, trade::identify_fee(s, place)) {
+ let Some(Stack { item: Item::Artifact(a), .. }) = h.kept_mut(at) else {
+ return String::new();
+ };
+ a.identified = true;
+ let offer = trade::artifact_offer(s, place, a);
+ format!(
+ "Ah. Your {} is {}. I'd give you {offer} ¤ for it.",
+ a.unidentified.trim_start_matches("a ").trim_start_matches("an "),
+ a.true_name
+ )
+ } else {
+ broke()
+ }
+ }
+ Deal::SellArtifact(at) => {
+ if !matches!(h.kept_mut(at), Some(Stack { item: Item::Artifact(_), .. })) {
+ return "You've nothing like that to sell me.".into();
+ }
+ let Item::Artifact(a) = h.take_kept(at, 1).item else { return String::new() };
+ let offer = trade::artifact_offer(s, place, &a);
+ let paid = h.earn(offer);
+ format!(
+ "{offer} ¤, as promised. {} will be well kept.{}",
+ capitalise(&a.true_name),
+ trade::debt_note(paid, h)
+ )
+ }
+ Deal::BuyPack => match items::next_pack(h.gear.capacity) {
+ Some((name, more, cost)) if pay(h, cost) => {
+ h.gear.capacity += more;
+ format!(
+ "One {name}, fitted to you. You can carry {} kg now.",
+ items::kg(h.gear.capacity)
+ )
+ }
+ Some(_) => broke(),
+ None => "That's the biggest pack there is. Any more and you'd need a mule.".into(),
+ },
+ Deal::BuyRound => {
+ if pay(h, trade::ROUND_PRICE) {
+ let rumour = &self.rumours[self.heard % self.rumours.len()];
+ self.heard += 1;
+ format!("The room warms to you. Someone leans over: “{rumour}”")
+ } else {
+ broke()
+ }
+ }
+ Deal::BuyRacks => match trade::rack_cost(h) {
+ Some(cost) if pay(h, cost) => {
+ h.ore_capacity += trade::RACK_TONS;
+ format!("Racks fitted. Your hold takes {} t of ore now.", h.ore_capacity)
+ }
+ Some(_) => broke(),
+ None => "Your hold's as big as it gets.".into(),
+ },
+ Deal::BuyFuel => match trade::buy_fuel(h, trade::fuel_price(s, place)) {
+ (0, _) if h.fuel >= h.fuel_capacity => "Your tank's full already.".into(),
+ (0, _) => broke(),
+ (jumps, cost) => format!(
+ "{jumps} jump{} of fuel for {cost} ¤, pumped aboard. Your tank holds {}/{}.",
+ if jumps == 1 { "" } else { "s" },
+ h.fuel,
+ h.fuel_capacity
+ ),
+ },
+ Deal::BuyTank => match trade::tank_cost(h) {
+ Some(cost) if pay(h, cost) => {
+ h.fuel_capacity += trade::TANK_JUMPS;
+ format!(
+ "The tank's welded in. You can carry {} jumps of fuel now; it's empty, mind.",
+ h.fuel_capacity
+ )
+ }
+ Some(_) => broke(),
+ None => "There's no room aboard for another tank.".into(),
+ },
+ Deal::BuyLens => match trade::lens_cost(ctx.lenses) {
+ Some(cost) if pay(h, cost) => {
+ ctx.lenses += 1;
+ "The new lens is ground and fitted. Your laser will cut faster.".into()
+ }
+ Some(_) => broke(),
+ None => "That's the finest lens there is.".into(),
+ },
+ }
+ }
+
+ fn trading_key(&mut self, key: KeyEvent, ctx: &mut Ctx) {
+ let Some(trading) = &self.trading else { return };
+ let shop = &self.town.shops[trading.shop];
+ let offers = self.offers(shop, ctx);
+ let choice = trading.choice.min(offers.len().saturating_sub(1));
+ let take = match key.code {
+ KeyCode::Esc | KeyCode::Char('q') => {
+ self.trading = None;
+ return;
+ }
+ KeyCode::Up | KeyCode::Char('k') => {
+ self.set_choice(choice.saturating_sub(1));
+ None
+ }
+ KeyCode::Down | KeyCode::Char('j') => {
+ self.set_choice((choice + 1).min(offers.len().saturating_sub(1)));
+ None
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => Some(choice),
+ KeyCode::Char(c @ '1'..='9') => Some(c as usize - '1' as usize),
+ _ => None,
+ };
+ if let Some(i) = take
+ && let Some(offer) = offers.get(i)
+ {
+ let reply = self.deal(offer.deal, ctx);
+ if let Some(trading) = &mut self.trading {
+ trading.reply = Some(reply);
+ trading.choice = i;
+ }
+ }
+ }
+
+ fn set_choice(&mut self, choice: usize) {
+ if let Some(trading) = &mut self.trading {
+ trading.choice = choice;
+ }
+ }
+}
+
+/// "north-east", for something `dx` across and `dy` down from you.
+fn compass(dx: i32, dy: i32) -> &'static str {
+ const WAYS: [&str; 8] =
+ ["east", "north-east", "north", "north-west", "west", "south-west", "south", "south-east"];
+ // Rows are twice as tall as columns are wide.
+ let angle = f64::from(-2 * dy).atan2(f64::from(dx));
+ let sector = (angle / std::f64::consts::FRAC_PI_4).round() as i32;
+ WAYS[sector.rem_euclid(8) as usize]
+}
+
+fn ore_worth(hold: &Hold, prices: &[(&'static crate::space::locale::Ore, u32)]) -> u32 {
+ prices.iter().map(|(ore, price)| hold.ore.get(ore.name).copied().unwrap_or(0) * price).sum()
+}
+
+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 TownScene {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ if self.trading.is_some() {
+ self.trading_key(key, ctx);
+ return Transition::None;
+ }
+ if self.inventory.is_some() {
+ self.inventory_key(key, ctx);
+ return Transition::None;
+ }
+ 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 => self.leave(),
+ Choice::Resume => Transition::None,
+ };
+ }
+ if self.map {
+ if matches!(key.code, KeyCode::Esc | KeyCode::Tab | KeyCode::Char('m' | 'M' | 'q')) {
+ self.map = false;
+ }
+ return Transition::None;
+ }
+ match key.code {
+ KeyCode::Char('i') => {
+ self.inventory = Some(Inventory::new());
+ return Transition::None;
+ }
+ KeyCode::Char('g' | ',') => {
+ self.pick_up(ctx);
+ return Transition::None;
+ }
+ KeyCode::Char('c' | 'C') => return Transition::Push(Box::new(Sheet::new())),
+ KeyCode::Char('m' | 'M') | KeyCode::Tab => {
+ self.map = true;
+ return Transition::None;
+ }
+ KeyCode::Char('t' | 'T') => {
+ self.talk_nearby();
+ return Transition::None;
+ }
+ KeyCode::Esc => {
+ let here = if self.inside.is_some() {
+ "Back out to the street"
+ } else {
+ "Back to your ship"
+ };
+ let note =
+ format!("In {}. Quit, and you'll come back aboard your ship.", self.town.name);
+ self.pause = Some(Pause::new(Some(here), Some(note)));
+ return Transition::None;
+ }
+ _ => {}
+ }
+ match keys::movement(key) {
+ Some((dx, dy, true)) => self.run(dx, dy),
+ Some((dx, dy, false)) => self.step(dx, dy),
+ None => Transition::None,
+ }
+ }
+
+ fn tick(&mut self, _ctx: &mut Ctx) -> Transition {
+ let player = self.player;
+ let site = match &mut self.inside {
+ Some(inside) => &mut inside.site,
+ None => &mut self.town.site,
+ };
+ for who in 0..site.people.len() {
+ if self.rng.random_range(0..WANDER_TICKS) == 0 {
+ site.wander(who, &mut self.rng, player);
+ }
+ }
+ 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.ticks);
+ self.draw_log(frame, log);
+ self.draw_panel(frame, panel, ctx);
+ if self.map {
+ self.draw_city(frame, map);
+ }
+ if let Some(trading) = &self.trading {
+ self.draw_counter(frame, map, trading, ctx);
+ }
+ if let Some(inv) = &self.inventory {
+ let floor = inventory::lying(&self.dropped, self.here());
+ let rack = if self.shopping.is_some() { self.rack(ctx) } else { Vec::new() };
+ let view = match self.shopping {
+ Some(_) => shop_view(ctx, self.place, &rack),
+ None => view(ctx, self.floor(), &floor),
+ };
+ inv.render(frame, frame.area(), &view);
+ }
+ if let Some(pause) = &self.pause {
+ pause.render(frame, frame.area());
+ }
+ }
+}
+
+/// What the inventory's shown: what you carry, and what's at your feet.
+fn view<'a>(ctx: &'a Ctx, beside: Beside, floor: &'a [Stack]) -> View<'a> {
+ View {
+ gear: &ctx.hold.gear,
+ beside: (!floor.is_empty()).then_some((beside, floor)),
+ known: &ctx.bestiary,
+ hurt: false,
+ shop: None,
+ sort: ctx.inventory_sort,
+ }
+}
+
+/// What the inventory's shown at an armourer's: what you carry and what's
+/// in your lockers, beside the `rack`, with the prices at `place`.
+fn shop_view<'a>(ctx: &'a Ctx, place: usize, rack: &'a [Stack]) -> View<'a> {
+ View {
+ gear: &ctx.hold.gear,
+ beside: Some((Beside::Rack, rack)),
+ known: &ctx.bestiary,
+ hurt: false,
+ shop: Some(Counter {
+ terms: trade::armoury_terms(&ctx.system, place),
+ aboard: &ctx.hold.lockers,
+ scrip: ctx.hold.scrip,
+ }),
+ sort: ctx.inventory_sort,
+ }
+}
+
+/// The armour in a stack, if that's what it is.
+fn armour_in(stack: Option<&mut Stack>) -> Option<&mut Armor> {
+ match stack {
+ Some(Stack { item: Item::Armor(a), .. }) => Some(a),
+ _ => None,
+ }
+}
+
+/// "the keen cleaver", "Khaseem's Mercy", "your cutting torch".
+fn the(w: &Weapon) -> String {
+ if w.named || w.kit { w.name() } else { format!("the {}", w.title) }
+}
+
+/// How someone looks.
+fn person_look(role: Role, theme: &Theme) -> (char, Rgb, bool) {
+ match role {
+ Role::Keeper(_) => ('&', (255, 214, 92), true),
+ Role::Ruler => ('&', (255, 236, 130), true),
+ Role::Stallholder => ('&', (255, 214, 140), false),
+ Role::Guard => ('&', (214, 100, 90), false),
+ Role::Priest => ('&', (226, 226, 255), false),
+ Role::Child => ('&', (250, 240, 200), false),
+ _ => ('&', theme.folk, false),
+ }
+}
+
+/// A fire's colour, flickering cell by cell.
+fn flicker(p: Pos, ticks: u64) -> Rgb {
+ const FLAMES: [Rgb; 3] = [(255, 150, 60), (255, 110, 40), (255, 190, 90)];
+ let k = (ticks / 5) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 13;
+ FLAMES[k.rem_euclid(3) as usize]
+}
+
+pub(crate) fn thing_look(thing: Thing, theme: &Theme, p: Pos, ticks: u64) -> (char, Rgb) {
+ const WOOD: Rgb = (156, 114, 72);
+ const DARK_WOOD: Rgb = (124, 88, 56);
+ const IRON: Rgb = (150, 156, 172);
+ const STONE: Rgb = (180, 176, 170);
+ match thing {
+ Thing::Table => ('╤', WOOD),
+ Thing::Chair => ('╥', DARK_WOOD),
+ Thing::Bed => ('Θ', theme.bed),
+ Thing::Hearth | Thing::Forge => ('^', flicker(p, ticks)),
+ Thing::Stove => ('◘', IRON),
+ Thing::Shelf => ('≡', (176, 150, 110)),
+ Thing::Chest => ('▪', (190, 146, 70)),
+ Thing::Crate => ('■', (170, 140, 96)),
+ Thing::Barrel => ('ö', (150, 104, 64)),
+ Thing::Altar => theme.altar,
+ Thing::Pew | Thing::Bench => ('═', WOOD),
+ Thing::Pillar => ('●', STONE),
+ Thing::Statue => ('ß', STONE),
+ Thing::Throne => ('Ψ', (240, 200, 90)),
+ Thing::Plant => ('♣', (90, 170, 80)),
+ Thing::Bath => {
+ let shimmer = (ticks / 9) as i64 + i64::from(p.x) * 3 + i64::from(p.y) * 5;
+ ('≈', if shimmer.rem_euclid(4) == 0 { (150, 210, 240) } else { (90, 160, 220) })
+ }
+ Thing::Anvil => ('π', IRON),
+ Thing::Loom => ('╫', WOOD),
+ Thing::Workbench => ('╦', WOOD),
+ Thing::Rack => ('/', IRON),
+ Thing::Lamp => ('¡', flicker(p, ticks)),
+ Thing::Engine => ('¤', (150, 172, 196)),
+ }
+}
+
+/// A colour made `k` times as bright.
+fn shade((r, g, b): Rgb, k: f64) -> Rgb {
+ let f = |c: u8| (f64::from(c) * k).round().clamp(0.0, 255.0) as u8;
+ (f(r), f(g), f(b))
+}
+
+/// How a building's roof looks at `p`: its colour, and the shop's sign or
+/// the landmark's mark painted on it.
+fn roof_look(t: &Town, b: usize, p: Pos, theme: &Theme) -> (char, Rgb, bool) {
+ let building = &t.buildings[b];
+ let h = seed::derive(b as u64, "roof", 0);
+ let colour =
+ shade(theme.roofs[(h % 3) as usize], 0.85 + 0.25 * ((h >> 8) % 100) as f64 / 100.0);
+ let centre = building.centre();
+ let grand = matches!(building.kind, Kind::Palace | Kind::Temple | Kind::Manor);
+ if let Some(shop) = building.shop {
+ let sign = t.shops[shop].kind.sign();
+ let start = centre.x - sign.len() as i32 / 2;
+ if p.y == centre.y && p.x >= start && p.x < start + sign.len() as i32 {
+ let ch = sign.as_bytes()[(p.x - start) as usize] as char;
+ return (ch, SIGN, true);
+ }
+ }
+ if p == centre {
+ match building.kind {
+ Kind::Palace => return ('Ψ', SIGN, true),
+ Kind::Temple => return (theme.altar.0, SIGN, true),
+ _ => {}
+ }
+ }
+ (if grand { '▓' } else { '▒' }, colour, false)
+}
+
+/// How a pond looks today.
+fn pond_look(theme: &Theme, p: Pos, ticks: u64) -> (char, Rgb) {
+ match theme.world.pond {
+ Pond::Water => ('≈', (80, 140, 210)),
+ Pond::Ice => ('░', (172, 210, 240)),
+ Pond::Dry => ('~', (150, 126, 94)),
+ Pond::Lava => ('≈', flicker(p, ticks)),
+ }
+}
+
+/// How a cell of the streets looks, leaving out people.
+pub(crate) fn street_look(t: &Town, p: Pos, theme: &Theme, ticks: u64) -> (char, Rgb, bool) {
+ let tile = t.tile(p);
+ let (glyph, colour) = match tile {
+ Tile::Ground => theme.world.ground,
+ Tile::Pad => ('·', (78, 82, 96)),
+ Tile::Ship => {
+ let (dx, dy) = (p.x - t.ship.x, p.y - t.ship.y);
+ let ch = SHIP_ART[dy as usize].as_bytes()[dx as usize] as char;
+ (ch, (206, 212, 228))
+ }
+ Tile::CityWall => theme.city_wall,
+ Tile::Gate | Tile::Street => theme.street,
+ Tile::Square => theme.square,
+ Tile::Garden => theme.world.garden,
+ Tile::Water => pond_look(theme, p, ticks),
+ Tile::Sea => {
+ let swell = (ticks / 12) as i64 + i64::from(p.x) * 5 + i64::from(p.y) * 11;
+ let crest = swell.rem_euclid(7) == 0;
+ ('≈', theme.world.sea[usize::from(crest)])
+ }
+ Tile::Decor => theme.decor.unwrap_or(theme.world.tree),
+ Tile::Tree => theme.world.tree,
+ Tile::Wild => match t.wild_sort(p) {
+ 1 if theme.world.vents => ('^', flicker(p, ticks)),
+ sort => theme.world.wild[sort],
+ },
+ Tile::Centrepiece => theme.centrepiece,
+ Tile::Stall => theme.stall,
+ Tile::Statue => ('ß', (200, 196, 190)),
+ Tile::Well => ('o', (110, 170, 230)),
+ Tile::Lamp => ('¡', flicker(p, ticks)),
+ Tile::Roof => match t.building_at(p) {
+ Some(b) => return roof_look(t, b, p, theme),
+ None => ('▒', theme.roofs[0]),
+ },
+ Tile::Door { open: true } => ('+', (236, 196, 120)),
+ Tile::Door { open: false } => ('+', (110, 88, 66)),
+ Tile::Wall | Tile::Floor | Tile::Doorway | Tile::Exit | Tile::Counter | Tile::Shelf => {
+ theme.floor
+ }
+ Tile::Rug => ('░', theme.rug),
+ Tile::Furniture(thing) => thing_look(thing, theme, p, ticks),
+ };
+ (glyph, colour, false)
+}
+
+/// How a cell inside a building looks, leaving out people.
+pub(crate) fn inside_look(
+ t: &Town,
+ inside: &Interior,
+ p: Pos,
+ theme: &Theme,
+ ticks: u64,
+) -> (char, Rgb, bool) {
+ let floor = match inside.kind {
+ Kind::Palace | Kind::Temple | Kind::Manor => ('·', (178, 172, 188)),
+ Kind::Shop(ShopKind::Tavern) | Kind::Workshop => ('.', (146, 112, 80)),
+ Kind::Warehouse | Kind::Guardhouse => ('.', (110, 108, 104)),
+ _ => theme.floor,
+ };
+ let (glyph, colour) = match inside.site.tile(p) {
+ Tile::Wall => theme.wall,
+ Tile::Floor => floor,
+ Tile::Doorway => ('\'', theme.wall.1),
+ Tile::Exit => ('+', (236, 196, 120)),
+ Tile::Counter => ('=', (190, 156, 112)),
+ Tile::Shelf => {
+ let shop = inside.shop.map(|s| t.shops[s].kind);
+ shop.map_or(('"', (150, 140, 120)), shelf)
+ }
+ Tile::Rug => ('░', theme.rug),
+ Tile::Furniture(thing) => thing_look(thing, theme, p, ticks),
+ _ => return street_look(t, p, theme, ticks),
+ };
+ (glyph, colour, false)
+}
+
+impl TownScene {
+ fn draw_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) {
+ let site = self.site();
+ let left = camera(self.player.x, site.width, i32::from(area.width));
+ let top = camera(self.player.y, site.height, i32::from(area.height));
+ let theme = theme(&self.town);
+ let folk: HashMap<Pos, &Person> = site.people.iter().map(|p| (p.pos, p)).collect();
+ let building = self.inside.as_ref().map(|i| i.building);
+ // What's been put down, the last thing on top.
+ let things: HashMap<Pos, &Stack> = self
+ .dropped
+ .iter()
+ .filter(|((b, _), _)| *b == building)
+ .map(|((_, p), s)| (*p, s))
+ .collect();
+ 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 !site.contains(p) {
+ (' ', (0, 0, 0), false)
+ } else if p == self.player {
+ ('@', (255, 255, 255), true)
+ } else if let Some(person) = folk.get(&p) {
+ person_look(person.role, &theme)
+ } else if let Some(stack) = things.get(&p) {
+ let (glyph, colour) = inventory::look(&stack.item);
+ (glyph, colour, false)
+ } else if let Some(inside) = &self.inside {
+ inside_look(&self.town, inside, p, &theme, ticks)
+ } else {
+ street_look(&self.town, p, &theme, ticks)
+ };
+ 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);
+ }
+ }
+ }
+ }
+
+ /// The whole city, shrunk to fit: each cell of it stands for a square
+ /// of streets, with the shops and landmarks marked.
+ fn draw_city(&self, frame: &mut Frame, area: Rect) {
+ let t = &self.town;
+ let theme = theme(t);
+ let mark = |glyph: char, what: &'static str| {
+ vec![
+ Span::raw(format!(" {glyph}")).fg(rgb(SIGN)).bold(),
+ Span::raw(format!(" {what}")).fg(DIM),
+ ]
+ };
+ let mut legend = vec![Span::raw(" @").white().bold(), Span::raw(" you").fg(DIM)];
+ // As many as fit along the bottom, whole.
+ let room = usize::from(area.width.saturating_sub(4));
+ for (glyph, what) in [
+ ('%', "ore"),
+ ('!', "doctor"),
+ ('≡', "scholar"),
+ ('o', "tavern"),
+ ('¤', "shipyard"),
+ (')', "arms"),
+ ('Ψ', "palace"),
+ (theme.altar.0, "temple"),
+ ] {
+ let used: usize = legend.iter().map(Span::width).sum();
+ if used + 3 + what.chars().count() > room {
+ break;
+ }
+ legend.extend(mark(glyph, what));
+ }
+ legend.push(Span::raw(" ").fg(DIM));
+ let block = Block::bordered()
+ .border_style(Style::new().fg(ACCENT))
+ .title(Line::from(format!(" {} ", t.name)).bold().fg(ACCENT))
+ .title(keys::line(&[("m esc", "close ")]).right_aligned())
+ .title_bottom(Line::from(legend));
+ let inner = block.inner(area);
+ frame.render_widget(Clear, area);
+ frame.render_widget(block, area);
+ let (aw, ah) = (i32::from(inner.width), i32::from(inner.height));
+ if aw <= 0 || ah <= 0 {
+ return;
+ }
+ let scale = ((t.width() + aw - 1) / aw).max((t.height() + ah - 1) / ah).max(1);
+ let (mw, mh) = ((t.width() + scale - 1) / scale, (t.height() + scale - 1) / scale);
+ let (ox, oy) = (i32::from(inner.x) + (aw - mw) / 2, i32::from(inner.y) + (ah - mh) / 2);
+ let buf = frame.buffer_mut();
+ let mut put = |x: i32, y: i32, glyph: char, colour: Rgb, bold: bool| {
+ let (Ok(x), Ok(y)) = (u16::try_from(x), u16::try_from(y)) else { return };
+ if !inner.contains((x, y).into()) {
+ return;
+ }
+ let cell = &mut buf[(x, y)];
+ cell.set_char(glyph).set_fg(rgb(colour));
+ if bold {
+ cell.modifier.insert(Modifier::BOLD);
+ }
+ };
+ for cy in 0..mh {
+ for cx in 0..mw {
+ let (glyph, colour) = self.summary(cx * scale, cy * scale, scale, &theme);
+ put(ox + cx, oy + cy, glyph, colour, false);
+ }
+ }
+ // Shops and landmarks, your ship, and you, where they'll go on the map.
+ let mut marks: Vec<(i32, i32, char, Rgb)> = Vec::new();
+ for b in &t.buildings {
+ let glyph = match b.kind {
+ Kind::Shop(kind) => shelf(kind).0,
+ Kind::Palace => 'Ψ',
+ Kind::Temple => theme.altar.0,
+ _ => continue,
+ };
+ marks.push((ox + b.door.x / scale, oy + b.door.y / scale, glyph, SIGN));
+ }
+ marks.push((ox + t.ship.x / scale, oy + t.ship.y / scale, '▲', (206, 212, 228)));
+ let you = self.inside.as_ref().map_or(self.player, |i| t.buildings[i.building].door);
+ marks.push((ox + you.x / scale, oy + you.y / scale, '@', (255, 255, 255)));
+ // Quarter names near the middle of each quarter, nudged up or down
+ // off the marks and each other, or left off if there's no room.
+ let mut taken: Vec<(i32, i32, i32)> = Vec::new();
+ for q in &t.quarters {
+ let len = q.name.chars().count() as i32;
+ let x0 = ox + q.centre.x / scale - len / 2;
+ if x0 < i32::from(inner.x) || x0 + len > i32::from(inner.x) + aw {
+ continue;
+ }
+ let free = |y: i32| {
+ y >= i32::from(inner.y)
+ && y < i32::from(inner.y) + ah
+ && !taken.iter().any(|&(ty, a, b)| ty == y && x0 <= b + 1 && x0 + len >= a - 1)
+ && !marks
+ .iter()
+ .any(|&(mx, my, ..)| my == y && (x0 - 1..=x0 + len).contains(&mx))
+ };
+ let y = oy + q.centre.y / scale;
+ let Some(y) = [0, -1, 1, -2, 2].into_iter().map(|dy| y + dy).find(|&y| free(y)) else {
+ continue;
+ };
+ taken.push((y, x0, x0 + len - 1));
+ for (i, ch) in q.name.chars().enumerate() {
+ put(x0 + i as i32, y, ch, (200, 204, 220), false);
+ }
+ }
+ for (x, y, glyph, colour) in marks {
+ put(x, y, glyph, colour, true);
+ }
+ }
+
+ /// One glyph for the `scale`-square of streets from `(x, y)`.
+ fn summary(&self, x: i32, y: i32, scale: i32, theme: &Theme) -> (char, Rgb) {
+ let t = &self.town;
+ // Walls count most, so the city's outline stays unbroken.
+ let mut weights = [0; 7];
+ for dy in 0..scale {
+ for dx in 0..scale {
+ let class = match t.tile(Pos::new(x + dx, y + dy)) {
+ _ if !t.site.contains(Pos::new(x + dx, y + dy)) => continue,
+ Tile::CityWall => (0, 8),
+ Tile::Street | Tile::Gate | Tile::Lamp => (1, 2),
+ Tile::Square | Tile::Stall | Tile::Centrepiece | Tile::Well | Tile::Statue => {
+ (2, 2)
+ }
+ Tile::Roof | Tile::Door { .. } => (3, 1),
+ Tile::Garden | Tile::Decor | Tile::Tree => (4, 1),
+ Tile::Water => (5, 3),
+ Tile::Sea => (6, 2),
+ _ => continue,
+ };
+ weights[class.0] += class.1;
+ }
+ }
+ let best = (0..weights.len()).max_by_key(|&i| (weights[i], usize::MAX - i)).unwrap_or(0);
+ if weights[best] == 0 {
+ return (theme.world.ground.0, shade(theme.world.ground.1, 0.6));
+ }
+ match best {
+ 0 => theme.city_wall,
+ 1 => theme.street,
+ 2 => theme.square,
+ 3 => ('▒', theme.roofs[0]),
+ 4 => theme.world.garden,
+ 5 => pond_look(theme, Pos::new(x, y), 0),
+ _ => ('≈', theme.world.sea[0]),
+ }
+ }
+
+ 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);
+ }
+
+ /// Where you are, for the panel: the quarter, or the building and room.
+ fn whereabouts(&self) -> Vec<String> {
+ let t = &self.town;
+ match &self.inside {
+ Some(inside) => {
+ let mut lines = vec![capitalise(&t.buildings[inside.building].name)];
+ if inside.room_count() > 1
+ && let Some(purpose) = inside.purpose_at(self.player)
+ {
+ lines.push(format!(" {}", words::room_name(purpose, t.culture)));
+ }
+ let (dx, dy) = (inside.exit.x - self.player.x, inside.exit.y - self.player.y);
+ if dx.abs() + dy.abs() > 1 {
+ lines.push(format!(" the way out is {}", compass(dx, dy)));
+ }
+ lines
+ }
+ None => {
+ let p = self.player;
+ let place = match t.tile(p) {
+ Tile::Pad => "the landing field".to_string(),
+ Tile::Ground => "outside the walls".to_string(),
+ _ if t.square.y == p.y && t.tile(p) == Tile::Square => {
+ "the market square".into()
+ }
+ _ => t.quarter_at(p).map_or("the streets".into(), |q| capitalise(&q.name)),
+ };
+ vec![capitalise(&place)]
+ }
+ }
+ }
+
+ fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
+ let t = &self.town;
+ let block = Block::bordered()
+ .border_style(Style::new().dark_gray())
+ .title(Line::from(format!(" {} ", t.name)).bold().fg(ACCENT))
+ .padding(Padding::horizontal(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let hints = keys::packed(
+ &[
+ ("arrows hjkl yubn", "walk"),
+ ("shift", "run"),
+ ("g", "pick up"),
+ ("i", "inventory"),
+ ("c", "character"),
+ ("t", "talk"),
+ ("m", "city map"),
+ ("bump", "go in, trade, talk"),
+ ("esc", "menu: leave or quit"),
+ ],
+ inner.width,
+ );
+ let [info, _, help] = Layout::vertical([
+ Constraint::Fill(1),
+ Constraint::Length(1),
+ Constraint::Length(hints.len() as u16),
+ ])
+ .areas(inner);
+
+ let h = &ctx.hold;
+ let label = |text: &'static str| Span::raw(text).fg(DIM);
+ let mut lines = vec![
+ Line::from(format!("capital of {}", self.world)).fg(DIM),
+ Line::from(format!("a city of {}", t.culture.name())).fg(DIM),
+ Line::default(),
+ ];
+ for line in self.whereabouts() {
+ lines.push(Line::from(line).fg(ACCENT));
+ }
+ 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)
+ )),
+ ]),
+ ]);
+ if h.debt > 0 {
+ lines.push(Line::from(vec![label("owed "), Span::raw(format!("{} ¤", h.debt))]));
+ }
+ // What you might sell here, on you or aboard.
+ for (at, stack) in h.kept() {
+ let colour = match &stack.item {
+ Item::Artifact(a) if a.identified => ACCENT,
+ Item::Artifact(_) => Color::Yellow,
+ Item::Salvage(_) => Color::Rgb(150, 200, 222),
+ _ => continue,
+ };
+ let (glyph, _) = inventory::look(&stack.item);
+ let text = format!("{}{}", stack.name(), at.note());
+ for (i, row) in
+ hanging(&text, usize::from(info.width).saturating_sub(2)).into_iter().enumerate()
+ {
+ let bullet = if i == 0 { format!("{glyph} ") } else { " ".into() };
+ lines.push(Line::from(format!("{bullet}{row}")).fg(colour));
+ }
+ }
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info);
+
+ frame.render_widget(Paragraph::new(hints), help);
+ }
+
+ /// The shop counter: the keeper, what they'll do for you, and what they
+ /// said last.
+ fn draw_counter(&self, frame: &mut Frame, area: Rect, trading: &Trading, ctx: &Ctx) {
+ let shop = &self.town.shops[trading.shop];
+ let offers = self.offers(shop, ctx);
+ let h = &ctx.hold;
+ let mut lines = vec![
+ Line::from(format!("{} says:", shop.keeper)).fg(DIM),
+ Line::from(format!("“{} {}”", salutation(self.town.culture), pitch(shop.kind))),
+ Line::default(),
+ ];
+ match shop.kind {
+ ShopKind::Broker => {
+ for (ore, price) in trade::ore_prices(&ctx.system, self.place) {
+ let have = h.ore.get(ore.name).copied().unwrap_or(0);
+ let have = if have > 0 { format!("you have {have} t") } else { String::new() };
+ lines.push(Line::from(vec![
+ Span::raw(" * ").fg(rgb(ore.rgb)),
+ Span::raw(format!("{:<16}{:>4} ¤ a ton ", ore.name, price)),
+ Span::raw(have).fg(DIM),
+ ]));
+ }
+ if h.ore_tons() == 0 && offers.is_empty() {
+ lines.push(Line::from("You've no ore or salvage to sell.").fg(DIM));
+ }
+ }
+ ShopKind::Doctor => lines.push(
+ Line::from(format!(
+ "You have {} medpatches, on you and aboard. I'll sell you up to {}.",
+ h.medpatches(),
+ trade::MEDPATCH_LIMIT
+ ))
+ .fg(DIM),
+ ),
+ ShopKind::Scholar if offers.is_empty() => {
+ lines.push(Line::from("You've no artifacts to show.").fg(DIM));
+ }
+ ShopKind::Shipyard => {
+ lines.push(
+ Line::from(format!(
+ "Fuel is {} ¤ a jump. Your tank holds {} of {}.",
+ trade::fuel_price(&ctx.system, self.place),
+ h.fuel,
+ h.fuel_capacity
+ ))
+ .fg(DIM),
+ );
+ if offers.is_empty() {
+ lines.push(Line::from("There's nothing more I can fit to your ship.").fg(DIM));
+ }
+ }
+ _ => {}
+ }
+ if !offers.is_empty() {
+ lines.push(Line::default());
+ }
+ let choice = trading.choice.min(offers.len().saturating_sub(1));
+ for (i, offer) in offers.iter().enumerate() {
+ let number = if i < 9 { format!("{} ", i + 1) } else { " ".into() };
+ let mut line =
+ Line::from(vec![Span::raw(number).fg(ACCENT), Span::raw(offer.text.clone())]);
+ if i == choice {
+ line = line.reversed();
+ }
+ lines.push(line);
+ }
+ if let Some(reply) = &trading.reply {
+ lines.push(Line::default());
+ lines.push(Line::from(reply.clone()).fg(ACCENT));
+ }
+ lines.push(Line::default());
+ lines.push(keys::line(&[("1-9 ↑↓ enter", "choose"), ("esc", "step away")]));
+
+ let [area] = Layout::horizontal([Constraint::Max(66)]).flex(Flex::Center).areas(area);
+ let width = usize::from(area.width.saturating_sub(4)).max(1);
+ let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum();
+ let height = u16::try_from(rows + 3).unwrap_or(u16::MAX);
+ let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area);
+ let block = Block::bordered()
+ .title(Line::from(format!(" {} ", shop.name)).bold().fg(ACCENT))
+ .border_style(Style::new().fg(ACCENT))
+ .padding(Padding::horizontal(1));
+ frame.render_widget(Clear, area);
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area);
+ }
+}
+
+/// Splits `text` into rows at most `width` wide, breaking between words, so
+/// a list item's later rows can be indented under its first.
+fn hanging(text: &str, width: usize) -> Vec<String> {
+ let mut rows: Vec<String> = Vec::new();
+ for word in text.split_whitespace() {
+ match rows.last_mut() {
+ Some(row) if row.chars().count() + 1 + word.chars().count() <= width => {
+ row.push(' ');
+ row.push_str(word);
+ }
+ _ => rows.push(word.to_string()),
+ }
+ }
+ rows
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::names;
+ use crate::scenes::tests::test_ctx;
+
+ fn press(scene: &mut TownScene, 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 capital of the homeworld.
+ fn landed() -> (TownScene, Ctx) {
+ let ctx = test_ctx();
+ let scene = TownScene::new(&ctx, ctx.system.home_place());
+ (scene, ctx)
+ }
+
+ /// Standing on the doorstep of building `b`, and stepping in.
+ fn walk_in(scene: &mut TownScene, ctx: &mut Ctx, b: usize) {
+ let building = &scene.town.buildings[b];
+ scene.player = building.front();
+ let (dx, dy) = building.facing.step();
+ // Clear the doorstep of anyone wandering by.
+ scene.town.site.people.retain(|p| p.pos != scene.player);
+ press(scene, ctx, key_towards(-dx, -dy));
+ }
+
+ /// A town on the homeworld, standing at the counter of its `kind` of shop.
+ fn at_counter(kind: ShopKind) -> (TownScene, Ctx) {
+ let (mut scene, ctx) = landed();
+ let shop = scene.town.shops.iter().position(|s| s.kind == kind).unwrap();
+ scene.trading = Some(Trading { shop, choice: 0, reply: None });
+ (scene, ctx)
+ }
+
+ fn reply(scene: &TownScene) -> &str {
+ scene.trading.as_ref().unwrap().reply.as_deref().unwrap()
+ }
+
+ #[test]
+ fn an_armourer_buys_and_sells_and_keeps_the_rack_for_the_fortnight() {
+ let (mut scene, mut ctx) = landed();
+ let shop = scene.town.shops.iter().position(|s| s.kind == ShopKind::Armoury).unwrap();
+ scene.open_shop(shop);
+ ctx.hold.gear = items::Gear::default();
+ ctx.hold.lockers.clear();
+ let terms = trade::armoury_terms(&ctx.system, scene.place);
+ let rack = scene.rack(&ctx);
+ let n = rack.len();
+
+ ctx.hold.scrip = 0;
+ assert!(scene.buy(0, &mut ctx).contains("Come back richer"));
+ let price = terms.asking(&rack[0].item).unwrap();
+ ctx.hold.scrip = price + 5;
+ let said = scene.buy(0, &mut ctx);
+ assert!(said.ends_with(&format!("is yours, for {price} ¤.")), "{said}");
+ assert_eq!((ctx.hold.scrip, scene.rack(&ctx).len()), (5, n - 1));
+ assert_eq!(ctx.hold.gear.pack, [rack[0].clone()]);
+
+ // Your own torch goes only with something else to hit things with.
+ let torch = Item::Weapon(ctx.hold.gear.melee.clone());
+ if matches!(&rack[0].item, Item::Weapon(w) if w.is_gun()) {
+ let said = scene.sell(Some(items::Spot::Melee), None, &mut ctx);
+ assert!(said.contains("nothing to hit things with"), "{said}");
+ assert_eq!(ctx.hold.scrip, 5);
+ let sold = scene.sell(Some(items::Spot::Pack(0)), None, &mut ctx);
+ assert!(sold.contains("It'll find a new owner"), "{sold}");
+ } else {
+ let said = scene.sell(Some(items::Spot::Melee), None, &mut ctx);
+ assert!(said.contains("your cutting torch"), "{said}");
+ assert_eq!(Item::Weapon(ctx.hold.gear.melee.clone()), rack[0].item, "readied instead");
+ let back = scene.rack(&ctx).iter().position(|s| s.item == torch).unwrap();
+ ctx.hold.scrip += 100;
+ let said = scene.buy(back, &mut ctx);
+ assert!(said.starts_with("You buy back your cutting torch"), "{said}");
+ }
+
+ // And from your ship's lockers.
+ ctx.hold.lockers.push(rack[1].clone());
+ let scrip = ctx.hold.scrip;
+ scene.sell(None, Some(0), &mut ctx);
+ assert!(ctx.hold.lockers.is_empty());
+ assert_eq!(ctx.hold.scrip, scrip + terms.offer(&rack[1].item).unwrap());
+ let now = scene.rack(&ctx);
+ let copies = now.iter().filter(|s| **s == rack[1]).count();
+ assert_eq!(copies, 2, "what you sell goes on the rack");
+
+ // Come back later in the fortnight and it's as you left it; the
+ // next, it's restocked.
+ let again = TownScene::new(&ctx, ctx.system.home_place());
+ assert_eq!(again.rack(&ctx), now);
+ ctx.day += trade::RESTOCK_DAYS;
+ assert_ne!(again.rack(&ctx), now);
+ }
+
+ #[test]
+ fn an_armourer_sells_armour_and_mends_yours() {
+ let (mut scene, mut ctx) = landed();
+ let shop = scene.town.shops.iter().position(|s| s.kind == ShopKind::Armoury).unwrap();
+ scene.open_shop(shop);
+ ctx.hold.gear = items::Gear::default();
+ ctx.hold.lockers.clear();
+ let terms = trade::armoury_terms(&ctx.system, scene.place);
+ let rack = scene.rack(&ctx);
+ let i =
+ rack.iter().position(|s| matches!(s.item, Item::Armor(_))).expect("armour on the rack");
+ let Item::Armor(piece) = rack[i].item.clone() else { unreachable!() };
+ assert!(rack[..i].iter().all(|s| matches!(s.item, Item::Weapon(_))), "weapons first");
+
+ ctx.hold.scrip = 1000;
+ let said = scene.buy(i, &mut ctx);
+ assert!(said.contains("yours, for"), "{said}");
+ assert_eq!(ctx.hold.scrip, 1000 - terms.asking(&rack[i].item).unwrap());
+ assert_eq!(scene.rack(&ctx).len(), rack.len() - 1);
+ ctx.hold.gear.wear(0).unwrap();
+
+ // Worn down, it's mended for what the wear's worth.
+ let slot = piece.slot;
+ ctx.hold.gear.worn.get_mut(&slot).unwrap().durability = 0;
+ let cost = terms.mending(&ctx.hold.gear.worn[&slot]);
+ assert!(cost > 0);
+ let scrip = ctx.hold.scrip;
+ let said = scene.mend(Some(items::Spot::Worn(slot)), None, &mut ctx);
+ assert!(said.contains("whole again"), "{said}");
+ assert_eq!(ctx.hold.scrip, scrip - cost);
+ assert_eq!(ctx.hold.gear.worn[&slot], piece);
+ let said = scene.mend(Some(items::Spot::Worn(slot)), None, &mut ctx);
+ assert!(said.contains("whole already"), "{said}");
+
+ // All at once: on you and aboard.
+ ctx.hold.gear.worn.get_mut(&slot).unwrap().durability = 1;
+ let mut aboard = piece.clone();
+ aboard.durability = 2;
+ ctx.hold.lockers.push(Stack::one(Item::Armor(aboard)));
+ ctx.hold.scrip = 0;
+ let said = scene.mend(None, None, &mut ctx);
+ assert!(said.contains("to mend all 2"), "{said}");
+ ctx.hold.scrip = 1000;
+ let said = scene.mend(None, None, &mut ctx);
+ assert!(said.starts_with("All 2 pieces are whole again"), "{said}");
+ assert_eq!(ctx.hold.lockers[0].item, Item::Armor(piece.clone()));
+ assert_eq!(scene.mend(None, None, &mut ctx), "Nothing of yours needs mending.");
+
+ // And sold back, off your back.
+ let said = scene.sell(Some(items::Spot::Worn(slot)), None, &mut ctx);
+ assert!(said.contains("It'll find a new owner"), "{said}");
+ assert!(ctx.hold.gear.worn.is_empty());
+ assert!(scene.rack(&ctx).contains(&Stack::one(Item::Armor(piece))));
+ }
+
+ #[test]
+ fn walking_up_to_a_counter_opens_the_shop() {
+ for kind in ShopKind::ALL {
+ let (mut scene, mut ctx) = landed();
+ let shop = scene.town.shops.iter().position(|s| s.kind == kind).unwrap();
+ let b = scene.town.shops[shop].building;
+ walk_in(&mut scene, &mut ctx, b);
+ let inside = scene.inside.as_ref().expect("the door opens");
+ assert_eq!(inside.building, b);
+ assert_eq!(scene.player, inside.start);
+ // Somewhere you can reach that's next to the counter.
+ let seen = inside.site.reachable(inside.start);
+ let (stand, counter) = inside
+ .site
+ .positions()
+ .filter(|&p| seen[(p.y * inside.site.width + p.x) as usize])
+ .find_map(|p| {
+ p.neighbours().find(|&n| inside.shop_at(n) == Some(shop)).map(|n| (p, n))
+ })
+ .unwrap_or_else(|| panic!("can't reach the {kind:?}'s counter"));
+ scene.inside.as_mut().unwrap().site.people.retain(|p| p.pos != stand || p.stays);
+ scene.player = stand;
+ press(&mut scene, &mut ctx, key_towards(counter.x - stand.x, counter.y - stand.y));
+ if kind == ShopKind::Armoury {
+ assert_eq!(scene.shopping, Some(shop), "an armourer shows you the rack");
+ assert!(scene.inventory.is_some() && scene.trading.is_none());
+ } else {
+ assert_eq!(scene.trading.as_ref().map(|t| t.shop), Some(shop));
+ }
+ press(&mut scene, &mut ctx, KeyCode::Esc);
+ assert!(scene.trading.is_none(), "esc steps away from the counter");
+ assert!(scene.inventory.is_none() && scene.shopping.is_none());
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None));
+ assert!(scene.pause.is_some(), "then opens the menu");
+ press(&mut scene, &mut ctx, KeyCode::Char('2'));
+ assert!(scene.inside.is_none(), "which takes you out to the street");
+ assert_eq!(scene.player, scene.town.buildings[b].front());
+ press(&mut scene, &mut ctx, KeyCode::Esc);
+ assert!(
+ matches!(press(&mut scene, &mut ctx, KeyCode::Char('2')), Transition::Pop),
+ "then off"
+ );
+ press(&mut scene, &mut ctx, KeyCode::Esc);
+ assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None));
+ assert!(scene.pause.is_none(), "esc again closes the menu");
+ press(&mut scene, &mut ctx, KeyCode::Esc);
+ assert!(
+ matches!(press(&mut scene, &mut ctx, KeyCode::Char('3')), Transition::Title),
+ "or save and quit"
+ );
+ }
+ }
+
+ #[test]
+ fn the_way_in_is_the_way_out() {
+ let (mut scene, mut ctx) = landed();
+ let b = (0..scene.town.buildings.len())
+ .find(|&b| {
+ !scene.town.buildings[b].barred && scene.town.buildings[b].kind == Kind::House
+ })
+ .unwrap();
+ walk_in(&mut scene, &mut ctx, b);
+ let inside = scene.inside.as_ref().unwrap();
+ let (exit, start) = (inside.exit, inside.start);
+ scene.inside.as_mut().unwrap().site.people.retain(|p| p.pos != exit);
+ assert_eq!(scene.player, start);
+ press(&mut scene, &mut ctx, key_towards(exit.x - start.x, exit.y - start.y));
+ assert!(scene.inside.is_none());
+ assert_eq!(scene.player, scene.town.buildings[b].front());
+ }
+
+ #[test]
+ fn barred_doors_stay_shut() {
+ let (mut scene, mut ctx) = landed();
+ let b = scene.town.buildings.iter().position(|b| b.barred).unwrap();
+ walk_in(&mut scene, &mut ctx, b);
+ assert!(scene.inside.is_none());
+ assert_eq!(scene.player, scene.town.buildings[b].front());
+ }
+
+ #[test]
+ fn walls_stop_you_and_streets_dont() {
+ let (mut scene, mut ctx) = landed();
+ let start = scene.player;
+ press(&mut scene, &mut ctx, KeyCode::Right);
+ assert_eq!(scene.player, start.offset(1, 0));
+ // The ship is west of where you start: walking into it leaves.
+ scene.player = start;
+ let mut left = Transition::None;
+ for _ in 0..3 {
+ left = press(&mut scene, &mut ctx, KeyCode::Left);
+ }
+ assert!(matches!(left, Transition::Pop));
+ }
+
+ #[test]
+ fn you_walk_through_people_who_are_wandering() {
+ let (mut scene, mut ctx) = landed();
+ // Out in the streets, three cells of street in a row.
+ let site = &scene.town.site;
+ let street = |p: Pos| site.tile(p) == Tile::Street && site.free(p);
+ let from = site
+ .positions()
+ .find(|&p| street(p) && street(p.offset(1, 0)) && street(p.offset(2, 0)))
+ .expect("a street");
+ scene.player = from;
+ let to = from.offset(1, 0);
+ scene.town.site.people.push(Person {
+ pos: to,
+ role: Role::Passerby,
+ name: None,
+ says: "Hello.",
+ stays: false,
+ });
+ let who = scene.town.site.people.len() - 1;
+ press(&mut scene, &mut ctx, KeyCode::Right);
+ assert_eq!(scene.player, to, "you walk on");
+ assert_ne!(scene.town.site.people[who].pos, to, "and they step aside");
+ // `t` talks to them anyway.
+ let said = scene.said;
+ press(&mut scene, &mut ctx, KeyCode::Char('t'));
+ assert_eq!(scene.said, said + 1);
+
+ // Someone who stays put is in the way, and bumping them talks.
+ scene.town.site.people[who].pos = to.offset(1, 0);
+ scene.town.site.people[who].stays = true;
+ press(&mut scene, &mut ctx, KeyCode::Right);
+ assert_eq!(scene.player, to);
+ assert!(scene.log.last().unwrap().contains("Hello."));
+ }
+
+ #[test]
+ fn running_goes_until_something_changes() {
+ let (mut scene, mut ctx) = landed();
+ let start = scene.player;
+ scene.town.site.people.clear();
+ scene.key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), &mut ctx);
+ let ran = scene.player.x - start.x;
+ assert!(ran > 3, "ran {ran}");
+ assert_eq!(scene.player.y, start.y);
+ let at = scene.player;
+ scene.key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), &mut ctx);
+ assert!(scene.player.x > at.x, "and on again");
+ }
+
+ #[test]
+ fn passers_by_point_the_way() {
+ let (mut scene, _) = landed();
+ let at = scene.town.square;
+ for _ in 0..20 {
+ let text = scene.directions(at).unwrap();
+ assert!(text.contains(" of here"), "{text}");
+ }
+ assert_eq!(compass(10, 0), "east");
+ assert_eq!(compass(0, -5), "north");
+ assert_eq!(compass(-6, 3), "south-west");
+ }
+
+ #[test]
+ fn insides_and_the_city_map_render_at_any_size() {
+ let (mut scene, mut ctx) = landed();
+ let mut scenes = Vec::new();
+ for kind in [Kind::Palace, Kind::Temple, Kind::Shop(ShopKind::Tavern)] {
+ let b = scene.town.buildings.iter().position(|b| b.kind == kind).unwrap();
+ walk_in(&mut scene, &mut ctx, b);
+ assert!(scene.inside.is_some());
+ scenes.push(scene.town.enter(b));
+ scene.go_out();
+ }
+ press(&mut scene, &mut ctx, KeyCode::Char('m'));
+ assert!(scene.map);
+ for (width, height) in [(200, 60), (80, 24), (30, 10), (3, 2)] {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+ terminal.draw(|frame| scene.render(frame, &ctx)).unwrap();
+ }
+ press(&mut scene, &mut ctx, KeyCode::Esc);
+ assert!(!scene.map);
+ for inside in scenes {
+ scene.player = inside.start;
+ scene.inside = Some(inside);
+ for (width, height) in [(200, 60), (80, 24), (30, 10), (3, 2)] {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+ terminal.draw(|frame| scene.render(frame, &ctx)).unwrap();
+ }
+ }
+ }
+
+ #[test]
+ fn the_broker_buys_your_ore() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Broker);
+ ctx.hold.ore.insert("nickel", 4);
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert!(reply(&scene).starts_with("Sold 4 t"));
+ assert_eq!(ctx.hold.ore_tons(), 0);
+ assert!(ctx.hold.scrip > 0);
+ }
+
+ #[test]
+ fn the_doctor_sells_medpatches_you_can_afford() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Doctor);
+ press(&mut scene, &mut ctx, KeyCode::Enter);
+ assert_eq!(reply(&scene), "You can't afford that.");
+ ctx.hold.scrip = 100;
+ press(&mut scene, &mut ctx, KeyCode::Enter);
+ assert_eq!(ctx.hold.gear.medpatches(), 1);
+ assert_eq!(ctx.hold.scrip, 100 - trade::medpatch_price(&ctx.system, scene.place));
+ // What won't fit on you goes to your ship.
+ ctx.hold.gear.capacity = ctx.hold.gear.carried();
+ press(&mut scene, &mut ctx, KeyCode::Enter);
+ assert!(reply(&scene).contains("sent to your ship"), "{}", reply(&scene));
+ assert_eq!((ctx.hold.gear.medpatches(), ctx.hold.medpatches()), (1, 2));
+ ctx.hold.lockers.push(Stack { item: Item::Medpatch, count: trade::MEDPATCH_LIMIT });
+ let scrip = ctx.hold.scrip;
+ press(&mut scene, &mut ctx, KeyCode::Enter);
+ assert_eq!(ctx.hold.scrip, scrip, "no more than the limit");
+ }
+
+ #[test]
+ fn long_names_wrap_between_words() {
+ assert_eq!(
+ hanging("the Voidsteel Crown of Drakion", 24),
+ ["the Voidsteel Crown of", "Drakion"]
+ );
+ assert_eq!(hanging("a disc", 24), ["a disc"]);
+ }
+
+ #[test]
+ fn the_scholar_names_artifacts_then_buys_them() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Scholar);
+ let artifact = names::artifact(&mut seed::rng(9));
+ let true_name = artifact.true_name.clone();
+ ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact)));
+ ctx.hold.scrip = 30;
+ assert!(screen(&scene, &ctx).contains("(in your ship)"));
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert!(matches!(&ctx.hold.lockers[0].item, Item::Artifact(a) if a.identified));
+ assert!(reply(&scene).contains(&true_name));
+ let before = ctx.hold.scrip;
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert!(ctx.hold.lockers.is_empty());
+ assert!(ctx.hold.scrip > before);
+ }
+
+ fn screen(scene: &TownScene, ctx: &Ctx) -> String {
+ let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
+ terminal.draw(|frame| scene.render(frame, ctx)).unwrap();
+ terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect()
+ }
+
+ fn salvage(title: &str, value: u32) -> Item {
+ Item::Salvage(items::Salvage { title: title.into(), value, mass: 1000 })
+ }
+
+ #[test]
+ fn the_broker_buys_salvage_on_you_or_aboard() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Broker);
+ ctx.hold.gear.add(Stack { item: salvage("coil of wire", 10), count: 3 });
+ ctx.hold.lockers.push(Stack::one(salvage("hull plate", 20)));
+ assert!(screen(&scene, &ctx).contains("Sell all your salvage"));
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert!(reply(&scene).contains("3 coils of wire"), "{}", reply(&scene));
+ assert!(ctx.hold.gear.pack.iter().all(|s| !matches!(s.item, Item::Salvage(_))));
+ let paid = ctx.hold.scrip;
+ assert!(paid >= 3 * 7, "about what it's worth");
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert!(ctx.hold.lockers.is_empty());
+ assert!(ctx.hold.scrip > paid);
+ }
+
+ #[test]
+ fn the_shipyard_sells_bigger_packs() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard);
+ ctx.hold.scrip = 5000;
+ ctx.hold.ore_capacity = 1000;
+ ctx.lenses = 10;
+ ctx.hold.fuel_capacity = trade::MAX_FUEL;
+ ctx.hold.fuel = trade::MAX_FUEL;
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert_eq!(ctx.hold.gear.capacity, items::BASE_CAPACITY + items::PACKS[0].1);
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert_eq!(ctx.hold.gear.capacity, items::max_capacity());
+ assert!(screen(&scene, &ctx).contains("nothing more"));
+ }
+
+ #[test]
+ fn what_you_drop_in_the_city_stays_put_until_you_leave() {
+ let (mut scene, mut ctx) = landed();
+ ctx.hold.gear.add(Stack::one(salvage("coil of wire", 10)));
+ let start = scene.player;
+ press(&mut scene, &mut ctx, KeyCode::Char('i'));
+ for c in "/wire".chars() {
+ press(&mut scene, &mut ctx, KeyCode::Char(c));
+ }
+ press(&mut scene, &mut ctx, KeyCode::Enter);
+ press(&mut scene, &mut ctx, KeyCode::Char('d'));
+ assert_eq!(inventory::lying(&scene.dropped, (None, start)).len(), 1);
+ press(&mut scene, &mut ctx, KeyCode::Char('q'));
+ assert!(scene.inventory.is_none());
+ press(&mut scene, &mut ctx, KeyCode::Char('g'));
+ assert!(scene.dropped.is_empty());
+ assert!(ctx.hold.gear.pack.iter().any(|s| matches!(s.item, Item::Salvage(_))));
+ }
+
+ #[test]
+ fn the_tavern_trades_rumours_for_rounds() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Tavern);
+ ctx.hold.scrip = 10;
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ let first = reply(&scene).to_string();
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert_ne!(reply(&scene), first, "a new rumour each round");
+ assert_eq!(ctx.hold.scrip, 10 - 2 * trade::ROUND_PRICE);
+ }
+
+ #[test]
+ fn the_shipyard_fits_racks_and_lenses() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard);
+ ctx.hold.scrip = 2000;
+ // A full tank: no fuel on offer, so racks are first.
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert_eq!(ctx.hold.ore_capacity, Hold::BASE_ORE_CAPACITY + trade::RACK_TONS);
+ press(&mut scene, &mut ctx, KeyCode::Char('2'));
+ assert_eq!(ctx.lenses, 1);
+ press(&mut scene, &mut ctx, KeyCode::Char('3'));
+ assert_eq!(ctx.hold.fuel_capacity, Hold::BASE_FUEL + trade::TANK_JUMPS);
+ }
+
+ #[test]
+ fn the_shipyard_sells_fuel() {
+ let (mut scene, mut ctx) = at_counter(ShopKind::Shipyard);
+ ctx.hold.fuel = 0;
+ ctx.hold.scrip = 2000;
+ press(&mut scene, &mut ctx, KeyCode::Char('1'));
+ assert_eq!(ctx.hold.fuel, ctx.hold.fuel_capacity);
+ assert!(reply(&scene).contains("4 jumps of fuel"), "{}", reply(&scene));
+ }
+}
diff --git a/src/scenes/zz_dump.rs b/src/scenes/zz_dump.rs
new file mode 100644
index 0000000..241f342
--- /dev/null
+++ b/src/scenes/zz_dump.rs
@@ -0,0 +1,62 @@
+//! Temporary: prints screens for a UI review.
+use super::tests::test_ctx;
+use super::*;
+use ratatui::Terminal;
+use ratatui::backend::TestBackend;
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+fn show(name: &str, scene: &dyn Scene, ctx: &Ctx, w: u16, h: u16) {
+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
+ t.draw(|f| scene.render(f, ctx)).unwrap();
+ let b = t.backend().buffer();
+ let cells: Vec<&str> = b.content.iter().map(|c| c.symbol()).collect();
+ println!("===== {name} ({w}x{h})");
+ for row in cells.chunks(w as usize) {
+ println!("{}", row.concat().trim_end());
+ }
+}
+fn k(s: &mut dyn Scene, ctx: &mut Ctx, c: KeyCode) {
+ s.key(KeyEvent::new(c, KeyModifiers::NONE), ctx);
+}
+
+#[test]
+fn zz_dump() {
+ let mut ctx = test_ctx();
+ let dir = crate::save::scratch("dump");
+ ctx.saves = Some(dir.clone());
+ ctx.slot = Some(crate::save::new_slot(&dir, &ctx.character, ctx.seed));
+ ctx.autosave();
+ let (w, h) = (120, 40);
+ show("title", &title::Title::new(&ctx), &ctx, w, h);
+ show("create", &character::Create::new(&ctx), &ctx, w, h);
+ show("sheet", &character::Sheet::new(), &ctx, w, h);
+ show("saves", &saves::Saves::new(&ctx), &ctx, w, h);
+ show("forge", &forge::Forge::new(&ctx), &ctx, w, h);
+ let home = ctx.system.home_place();
+ let mut a = aboard::Aboard::landed(&ctx, home);
+ show("aboard", &a, &ctx, w, h);
+ k(&mut a, &mut ctx, KeyCode::Esc);
+ show("aboard pause", &a, &ctx, w, h);
+ let mut a = aboard::Aboard::landed(&ctx, home);
+ k(&mut a, &mut ctx, KeyCode::Char('i'));
+ show("aboard inv", &a, &ctx, w, h);
+ let mut t = town::TownScene::new(&ctx, home);
+ show("town", &t, &ctx, w, h);
+ k(&mut t, &mut ctx, KeyCode::Char('m'));
+ show("town map", &t, &ctx, w, h);
+ let mut f = flight::FlightScene::new(&ctx);
+ show("flight", &f, &ctx, w, h);
+ show("flight narrow", &f, &ctx, 80, 24);
+ show("aboard 80", &aboard::Aboard::landed(&ctx, home), &ctx, 80, 24);
+ show("derelict 80", &derelict::Derelict::new(2), &ctx, 80, 24);
+ k(&mut f, &mut ctx, KeyCode::Esc);
+ show("flight pause", &f, &ctx, w, h);
+ show("system map", &system_map::SystemMap::new(&ctx), &ctx, w, h);
+ show("galaxy map", &galaxy_map::GalaxyMap::new(&ctx), &ctx, w, h);
+ show("port station", &port::Port::new(&ctx, 0), &ctx, w, h);
+ let mut d = derelict::Derelict::board(2, &mut ctx);
+ show("derelict", &d, &ctx, w, h);
+ k(&mut d, &mut ctx, KeyCode::Char('f'));
+ show("derelict aim", &d, &ctx, w, h);
+ let _ = std::fs::remove_dir_all(dir);
+}